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.

Routing Context & Middleware Validation Gate
User Requests Route (e.g., /tenant-plans)
Next.js Sidebar / Navigation checks RequiresTenantContext flag
Client verification: useAppStore checks if tenantId !== null
Axios Request: injects X-Tenant-Context header into API call
TenantContextMiddleware: checks X-Tenant-Context header presence
Middleware checks user HasPermission('tenants.drill_down')
GetMyMenuQuery / Controller checks currentUser.EffectiveTenantId
Authorized -> Serve Page | Unauthorized -> 403 Forbidden Response

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.

GetMyMenuQueryHandler.csC#
// 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.

TenantPlansController.csC#
[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);
}
TenantContextMiddleware.csC#
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:

Drill-Down Behavior
System Admin (no TenantId in JWT)
Opens Tenant list, clicks 'Drill Down'
Backend adds DrillDownTenantId to JWT context
EffectiveTenantId = DrillDownTenantId ≠ null
RequiresTenantContext pages now accessible
Admin sees tenant data with full permissions

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.

ModeWho is the user?PermissionsTenantId source
Normal (no context)System Admin themselvesSystem admin bypass (all)null — RequiresTenantContext blocked
Drill-DownSystem Admin themselvesSystem admin bypass (all)DrillDownTenantId from session
ImpersonationThe impersonated adminThat admin's roles/permissions onlyImpersonated 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.

MenuItemSeeder.csC#
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.