mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix(security): enforce tenant suspension in PolicyEnforcementMiddleware, not only auth/me
TenantConfiguration.Disabled/DisabledUntil was checked solely in AuthFunction.BuildAuthResult (GET /api/auth/me), so a direct REST caller with a valid token kept full read/write access for the whole suspension window and during an offboarding wipe. DecideAsync now denies 403 TenantSuspended when the caller's home tenant or the request's target tenant is currently disabled (cached, side-effect-free config read). GA/GlobalReader bypass to administer the tenant; device/anonymous routes keep their SecurityValidator gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1308,4 +1308,119 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(string.Empty, result.Context!.CallerId);
|
||||
}
|
||||
|
||||
// ── Tenant suspension gate (TenantConfiguration.Disabled / DisabledUntil) ──────────
|
||||
// The operator kill-switch must revoke API access on the shared authorization path, not only
|
||||
// on the SPA's auth/me probe — a direct REST caller with a valid token must get 403 too.
|
||||
|
||||
private static void AsSuspended(Harness h, string tenantId, DateTime? until = null, string? reason = null) =>
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Disabled = true,
|
||||
DisabledUntil = until,
|
||||
DisabledReason = reason,
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_HomeTenant_ReadRoute_IsForbidden_WithTenantSuspendedCode()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA, until: DateTime.UtcNow.AddDays(30), reason: "Abuse investigation");
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
Assert.Equal("Abuse investigation", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_HomeTenant_WriteRoute_IsForbidden()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA); // indefinite suspension (no DisabledUntil)
|
||||
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_ExpiredDisabledUntil_IsAllowed()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA, until: DateTime.UtcNow.AddMinutes(-1)); // suspension window elapsed
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_GlobalAdmin_BypassesGate_ToAdministerTenant()
|
||||
{
|
||||
const string upn = "ga@platform.example";
|
||||
var h = BuildHarness();
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
AsSuspended(h, TenantA);
|
||||
AsSuspended(h, TenantB);
|
||||
|
||||
var own = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
var cross = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(own.Allowed);
|
||||
Assert.True(cross.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_DelegatedReader_TargetTenantSuspended_IsForbidden()
|
||||
{
|
||||
const string upn = "msp@partner.example";
|
||||
var h = BuildHarness();
|
||||
h.AsDelegated(TenantB);
|
||||
AsSuspended(h, TenantB); // home tenant A is fine, managed target B is suspended
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_DelegatedReader_HomeTenantSuspended_LosesDelegatedReach()
|
||||
{
|
||||
const string upn = "msp@partner.example";
|
||||
var h = BuildHarness();
|
||||
h.AsDelegated(TenantB);
|
||||
AsSuspended(h, TenantA); // the MSP's own home tenant is suspended
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_DeviceRoute_NoPrincipal_GateNotApplied()
|
||||
{
|
||||
// Anonymous/device routes carry no principal — SecurityValidator owns their tenant gate. The
|
||||
// middleware must not touch tenant config here (no tenant to key on).
|
||||
var h = BuildHarness();
|
||||
AsSuspended(h, TenantA);
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/health", null, null);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
@@ -212,6 +213,29 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
decision.UserIdentifier, decision.UserRole, "CrossTenant");
|
||||
}
|
||||
|
||||
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
|
||||
// the offboarding tombstone. Enforced HERE, on the shared authorization path, so it covers every
|
||||
// tenant-scoped API call and not just the SPA's auth/me login probe (which also carries it, for the
|
||||
// richer client payload). Both the caller's HOME tenant (JWT tid) and the request's TARGET tenant are
|
||||
// checked: a suspended tenant's members lose everything, including delegated reach into other tenants,
|
||||
// and nobody without platform scope reaches a suspended target. Platform operators (GA/Reader) bypass —
|
||||
// they must still administer the suspended tenant. Anonymous/device routes bring their own gate
|
||||
// (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled.
|
||||
if (principal != null && !hasGlobalScope)
|
||||
{
|
||||
var suspended = await FindSuspendedTenantAsync(jwtTenantId, targetTenantId);
|
||||
if (suspended != null)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}",
|
||||
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(suspended.TenantId), logPath);
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "TenantSuspended",
|
||||
!string.IsNullOrEmpty(suspended.DisabledReason)
|
||||
? suspended.DisabledReason
|
||||
: "Your tenant has been suspended. Please contact support for more information.",
|
||||
decision.UserIdentifier, decision.UserRole, "TenantSuspended");
|
||||
}
|
||||
}
|
||||
|
||||
// Merge delegated state from the two delegated admission paths:
|
||||
// • scoped-route (RouteParam/QueryParam): a single-tenant cross-tenant read — delegatedRole +
|
||||
// allowedTenantIds were set above.
|
||||
@@ -259,6 +283,26 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return PolicyResult.Allow(requestContext, decision.UserIdentifier, decision.UserRole);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the configuration of the first currently-suspended tenant among the caller's home tenant and
|
||||
/// the request target (null when neither is suspended). <see cref="TenantConfigurationService.TryGetConfigurationAsync"/>
|
||||
/// is cached (5 min TTL, same as the agent gate) and never persists a default row.
|
||||
/// <see cref="TenantConfiguration.IsCurrentlyDisabled"/> already treats an elapsed DisabledUntil as
|
||||
/// re-enabled, so the gate lifts on its own; auth/me persists the flip on the next login.
|
||||
/// </summary>
|
||||
private async Task<TenantConfiguration?> FindSuspendedTenantAsync(string jwtTenantId, string targetTenantId)
|
||||
{
|
||||
var (homeConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(jwtTenantId);
|
||||
if (homeConfig.IsCurrentlyDisabled())
|
||||
return homeConfig;
|
||||
|
||||
if (string.Equals(targetTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var (targetConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(targetTenantId);
|
||||
return targetConfig.IsCurrentlyDisabled() ? targetConfig : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the request's target tenant. Returns the effective TargetTenantId (defaults to the JWT
|
||||
/// tenant) and the explicitly-named tenant from the route/query (null when the route does not name one,
|
||||
|
||||
Reference in New Issue
Block a user