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,