From 43ddc601113d7ed1d94d4df1368d99134287bb71 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 29 Aug 2026 22:34:29 +0200 Subject: [PATCH] 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 --- .../PolicyEnforcementMiddlewareTests.cs | 115 ++++++++++++++++++ .../Middleware/PolicyEnforcementMiddleware.cs | 44 +++++++ 2 files changed, 159 insertions(+) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 9ee01636..3bec7a5a 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -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()), Times.Never); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index a2b37f3e..2578d0eb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -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); } + /// + /// Returns the configuration of the first currently-suspended tenant among the caller's home tenant and + /// the request target (null when neither is suspended). + /// is cached (5 min TTL, same as the agent gate) and never persists a default row. + /// already treats an elapsed DisabledUntil as + /// re-enabled, so the gate lifts on its own; auth/me persists the flip on the next login. + /// + private async Task 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; + } + /// /// 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,