Tenant Context Gate
RequiresTenantContext flag, multi-layer menu visibility defense, drill-down behavior, and impersonation scoping for tenant-only pages.
The Tenant Context Gate is a security mechanism that prevents system admins from accidentally (or intentionally) accessing tenant-scoped pages when they have no active tenant context. Pages like Tenant Plans, User Subscriptions, and the Customizer Studio only make sense within a specific tenant's context – showing them to a system admin with no tenant would either show incorrect data or expose cross-tenant information.
The Problem
System super-admins have a bypass flag (IsSystemProtectedAdmin) that normally grants them access to all pages. Without a gate, a super-admin with no tenant context could navigate to /tenant-plans and see data from all tenants, or crash the page because no TenantId is available.
The Solution: RequiresTenantContext
We introduced the RequiresTenantContext boolean flag in the DocNavigationItem schema. When this flag is set to true, the frontend actively checks if the current user has a valid tenantId. If they do not, the item is completely stripped from the navigation menu and the route redirects to the overview page.
Multi-Layer Defense in Depth
The gate operates across three independent architectural defense layers:
Layer 1: Frontend Menu Visibility Filter
The menu generation pipeline inspects the RequiresTenantContext flag on each menu item. If the current user does not have an active EffectiveTenantId, the node is completely stripped from the menu tree before reaching the client.
// Applied BEFORE system-admin bypass — it's an absolute gate
if (menuItem.RequiresTenantContext && currentUser.EffectiveTenantId == null)
{
continue; // Exclude this menu item — no tenant context available
}
// System admin bypass is evaluated AFTER RequiresTenantContext
if (currentUser.IsSystemProtectedAdmin)
{
authorizedItems.Add(menuItem);
continue;
}Layer 2: Client-Side Route Guards
Next.js middleware and client page wrappers inspect useAppStore to verify an active tenant context before mounting. Unauthorized attempts redirect automatically to the workspace overview.
Layer 3: Backend Controller & Middleware Firewall
Controllers and CQRS handlers independently validate the tenant context, returning 401 Unauthorized or 403 Forbidden if the request does not provide a valid tenant context.
[HttpGet]
[Authorize]
[PermissionRequired("tenant_plans.view")]
public async Task<IActionResult> GetAll(int page = 1, int pageSize = 20)
{
// Layer 3 fail-safe — controller validates tenant context independently
var tenantId = _currentUser.EffectiveTenantId;
if (tenantId == null)
return Unauthorized("Tenant context required for this endpoint.");
var result = await _sender.Send(new GetTenantPlansQuery(tenantId.Value, page, pageSize));
return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
}public class TenantContextMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TenantContextMiddleware> _logger;
private const string TenantContextHeader = "X-Tenant-Context";
private const string DrillDownPermission = "tenants.drill_down";
public TenantContextMiddleware(RequestDelegate next, ILogger<TenantContextMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, ICurrentUser currentUser)
{
var tenantContextHeader = context.Request.Headers[TenantContextHeader].FirstOrDefault();
if (!string.IsNullOrEmpty(tenantContextHeader))
{
// Validate that the user switching context has tenants.drill_down permission
if (!currentUser.HasPermission(DrillDownPermission))
{
_logger.LogWarning("User {UserId} attempted context switch without {Permission} permission", currentUser.Id, DrillDownPermission);
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
context.Response.ContentType = "application/json";
var error = new { error = "TENANT_CONTEXT_FORBIDDEN", message = "Missing permission: tenants.drill_down", code = 403 };
await context.Response.WriteAsJsonAsync(error);
return;
}
}
await _next(context);
}
}Drill-Down and Impersonation
System admins can still access these pages, but only through explicit context-switching mechanisms:
User Impersonation Scoping
During user impersonation, the security pipeline swaps the claims principal with a tenant-scoped session token, inheriting exact tenant boundaries and permissions.
| Mode | Who is the user? | Permissions | TenantId source |
|---|---|---|---|
| Normal (no context) | System Admin themselves | System admin bypass (all) | null — RequiresTenantContext blocked |
| Drill-Down | System Admin themselves | System admin bypass (all) | DrillDownTenantId from session |
| Impersonation | The impersonated admin | That admin's roles/permissions only | Impersonated admin's TenantId |
Protected Tenant-Only Pages
The following administrative views strictly enforce tenant context gates:
- Tenant Subscription Plans & Billing Configuration
- User Subscriptions & Entitlements Allocation
- Tenant-Scoped Customizer Studio & Theme Customization
- Tenant Organizational Units & Department Topologies
- Tenant Integration Credentials & Webhook Endpoints
- Tenant-Scoped Audit Logs & Security Event Streams
Adding the RequiresTenantContext Flag
To protect a new menu item, set RequiresTenantContext = true in the MenuItem entity or seeder definition.
new MenuItem
{
Slug = "my-tenant-module",
Name = "My Tenant Module",
Icon = "Building",
Order = 15,
ParentSlug = null,
RequiredPermission = "my_module.view",
RequiresTenantContext = true, // ← This is the gate
},MenuItemSeeder Configuration Pattern
MenuItem seeders declare RequiresTenantContext as part of the initial platform bootstrapping, ensuring tenant boundaries are locked from the first deployment.