Multi-tenant API plug for domain-based tenant resolution and context management.
The ApiTenantLookupPlug provides sophisticated tenant identification for REST APIs
in multi-tenant MishkaCms applications. It automatically resolves the current tenant
based on request domain and integrates with Ash resource authorization. It never widens what a
caller may reach: a request is scoped to the site its host names, and only a master (a user whose
site_id is nil) is recognised on a request left unscoped.
Features
- Domain-based Tenant Resolution: Automatically identifies tenants from request Host header
- Ash Framework Integration: Sets tenant context for Ash resource authorization
- Master Accounts: On the master site's address,
x-no-tenantleaves a request unscoped - Site State Caching: Leverages efficient SiteState cache for tenant lookups
- Comprehensive Error Handling: Provides detailed API error responses for tenant issues
- Test Environment Support: Includes test-friendly host extraction for development
- Production Optimization: Optimized performance for high-throughput API endpoints
Multi-Tenant Architecture
The plug operates within MishkaCms's multi-tenant architecture:
API Request → Domain Resolution → Site Lookup → Tenant Context
├── Host Header Extraction
├── SiteState Cache Lookup
├── Site Activation Check
├── Tenant Context Setting (Ash)
└── Response Headers & AssignsBasic Usage
# Add to your API pipeline
defmodule MyAppWeb.Router do
use Phoenix.Router
pipeline :api do
plug :accepts, ["json"]
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug
plug :fetch_query_params
end
scope "/api/v1", MyAppWeb.Api do
pipe_through :api
resources "/posts", PostController
resources "/users", UserController
end
end
# In your API controller
defmodule MyAppWeb.Api.PostController do
use MyAppWeb, :controller
def index(conn, _params) do
# Tenant context automatically set
site = conn.assigns.current_site
posts = MyApp.Blog.list_posts!(tenant: site.id)
render(conn, :index, posts: posts)
end
endAdvanced Configuration
# Router with custom options
pipeline :unscoped_api do
plug :accepts, ["json"]
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug,
allow_no_tenant: true # A request from no known host goes on unscoped
end
pipeline :tenant_api do
plug :accepts, ["json"]
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug,
allow_no_tenant: false # Strict tenant enforcement
endThe Master Accounts
A master is a user with no site, and their token is issued with no site. On the master site's
address — the site whose record has master: true — x-no-tenant: true or ?no_tenant=true
leaves the request unscoped, so :load_from_bearer recognises that token:
# Via HTTP header, on the master site's address
curl -H "Host: master.example.com" -H "x-no-tenant: true" http://localhost:4000/api/json/v1/master/admin/runtime/site
# Via query parameter
GET /api/json/v1/master/admin/runtime/site?no_tenant=true
Host: master.example.comEvery other site ignores the switch and scopes the request to itself, so a master's email and
password are wrong there and a master's token names nobody. confirm_bearer_tenant/2, straight
after :load_from_bearer, holds the rest: an unscoped request carries a master or nobody.
In a build whose config sets :env_dev? or :env_test?, localhost and 127.0.0.1 are the
master site's address while no site has that host, as they are in the browser
(MishkaCmsCore.Runtime.Plugs.SiteLookupPlug.site_for/1), and the switch works there as it does
on the master site's own address. A release has no such address.
# In dev, while no site's host is localhost
curl -H "Host: localhost:4000" -H "x-no-tenant: true" http://localhost:4000/api/json/v1/master/admin/runtime/siteAn unscoped request reads and writes through the master routes. A route that works on one site —
the public /v1/user/… ones and the tenant admin ones — answers it 400 with the code
tenant_required (_tenant_required_errors.ex):
{"errors": [{"status": "400", "code": "tenant_required", "title": "Tenant required",
"detail": "Tenant context is required for this action. Use the master action instead."}]}Error Handling and API Responses
# Missing Host header
# Status: 400 Bad Request
{
"error": {
"code": "missing_host",
"message": "Host header is required",
"status": 400,
"timestamp": "2024-01-15T10:30:45Z"
}
}
# Domain not found
# Status: 404 Not Found
{
"error": {
"code": "domain_not_found",
"message": "Domain not found",
"status": 404,
"timestamp": "2024-01-15T10:30:45Z"
}
}
# Site inactive
# Status: 503 Service Unavailable
{
"error": {
"code": "website_unavailable",
"message": "This website is currently unavailable",
"status": 503,
"timestamp": "2024-01-15T10:30:45Z"
}
}The inactive refusal is answered for the site's CORS first — see
MishkaCmsCore.Runtime.Plugs.ApiCorsPlug — so a frontend on an origin the site allows gets its
preflight answered and can read the 503. It is sent with cache-control: no-store.
An offline site passes this plug: who may reach it depends on the caller, who is known only once the
bearer token is loaded. MishkaCmsCore.Runtime.Plugs.ApiAvailabilityPlug answers that.
Events
api:request:site_unavailable— the conn of a switched-off site about to be refused, with%{state: :inactive, site: site}as its meta. A plugin may answer instead by halting the conn.api:request:no_tenant_refused— the conn of a site that is not the master site, asked forx-no-tenantor?no_tenant=trueand scoped to itself anyway, with%{site: site}as its meta.api:request:token_refused— the conn of an unscoped request whose token named a user with a site, who is dropped, with%{site: site, user_id: id}as its meta.
Summary
Functions
Processes the request to identify and set the tenant context.
This is the main plug function that executes for each request. It implements a comprehensive tenant resolution pipeline including domain extraction, site lookup, validation, and context setting for downstream processing.
Parameters
conn- Plug.Conn struct representing the HTTP request/responseopts- Configuration options frominit/1
Return Value
Plug.Conn- Updated connection with tenant context or error response
Processing Pipeline
- Host Extraction: Extracts domain from Host header or conn.host (tests)
- Site Lookup: Queries SiteState cache for site configuration
- Site Validation: Ensures site is active and available
- Tenant Override Check: Handles explicit no-tenant requests
- Context Setting: Configures Ash tenant context and response headers
- Assignment: Sets
current_siteassign for controller access
Successful Flow
# Request: GET /api/posts
# Header: Host: blog.example.com
# 1. Extract host: "blog.example.com"
# 2. Lookup site: %Site{id: 42, host: "blog.example.com", active: true}
# 3. Validate: site active ✓
# 4. Check override: no x-no-tenant header ✓
# 5. Set tenant context: Ash.set_tenant(42)
# 6. Add headers: x-tenant-id: "42"
# 7. Assign: conn.assigns.current_site = %Site{...}Error Handling
The function handles multiple error scenarios:
# Missing Host header
# Result: 400 with detailed JSON error (if allow_no_tenant: false)
# Result: current_site: nil (if allow_no_tenant: true)
# Unknown domain
# Result: 404 with detailed JSON error (if allow_no_tenant: false)
# Result: current_site: nil (if allow_no_tenant: true)
# Inactive site
# Result: 503 with "website_unavailable" error (always)Examples
# Standard tenant request
conn = %Plug.Conn{
req_headers: [$("host", "mysite.com")]
}
updated_conn = ApiTenantLookupPlug.call(conn, allow_no_tenant: false)
# Result: conn.assigns.current_site = %Site{id: 123, host: "mysite.com"}
# Headers: x-tenant-id: "123"
# The master accounts' switch, on the master site's address
conn = %Plug.Conn{
req_headers: [
$("host", "master.example.com"),
$("x-no-tenant", "true")
]
}
updated_conn = ApiTenantLookupPlug.call(conn, [])
# Result: conn.assigns.current_site = %Site{master: true, ...}
# Headers: x-tenant-id: "none"
# Ash tenant context: nil
# On any other site's address: x-tenant-id is the site's id, and so is the tenantIntegration with Controllers
defmodule MyAppWeb.Api.PostController do
def index(conn, _params) do
site = conn.assigns.current_site
if site do
# Tenant-specific posts (Ash context set automatically)
posts = MyApp.Blog.list_posts!()
render(conn, :index, posts: posts)
else
# Global posts (no tenant context)
posts = MyApp.Blog.list_all_posts!()
render(conn, :global_index, posts: posts)
end
end
endMonitoring and Debugging
# Add custom telemetry
defmodule TenantLookupTelemetry do
def track_tenant_resolution(conn, result) do
:telemetry.execute(
[:api, :tenant_lookup],
%{duration: processing_time},
%{
host: get_req_header(conn, "host") |> List.first(),
result: result,
tenant_id: conn.assigns[:current_site] && conn.assigns.current_site.id
}
)
end
endSecurity Considerations
# The plug automatically prevents:
# - Cross-tenant data access (via Ash context)
# - Inactive site access (503 responses)
# - Malformed host header attacks (validation)
# Additional security can be layered:
plug ApiTenantLookupPlug
plug TenantRateLimitPlug
plug TenantPermissionPlug
@spec confirm_bearer_tenant(Plug.Conn.t(), term()) :: Plug.Conn.t()
Confirms the tenant call/2 chose, once :load_from_bearer has read the token's user.
Plug it in straight after :load_from_bearer, imported from this module:
import MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug, only: [confirm_bearer_tenant: 2]
pipeline :api do
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug
# ...
plug :load_from_bearer
plug :confirm_bearer_tenant
plug :set_actor, :user
endAn unscoped request is a master's, a user whose site_id is nil, or nobody's. Anyone else on an
unscoped request — a user with a site holding a token issued with no site, or an actor that
carries no site_id at all — is nobody: :current_user becomes nil, and a plugin on
api:request:token_refused hears it with %{site: site, user_id: id}.
A user the site's closed sign-in signs out (MishkaCmsCore.Accounts.AuthGate.signs_out?/2) is
signed out everywhere and is nobody too.
Initializes the plug with configuration options.
This function merges user-provided options with default settings to configure the plug's behavior. It's called once when the plug is compiled into the router pipeline, not on each request.
Parameters
opts- Keyword list of configuration options::allow_no_tenant- Boolean, lets a request from a missing or unknown host go on with no tenant, where only a master is recognised (defaults tofalse)
Return Value
Returns merged configuration that will be passed to call/2 function.
Examples
# Default configuration
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug
# Equivalent to: plug ..., allow_no_tenant: false
# Let a request from a missing or unknown host go on unscoped
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug, allow_no_tenant: trueConfiguration Options
:allow_no_tenant
When true:
- Missing host headers result in
current_site: nil - Unknown domains result in
current_site: nil - Such a request goes on unscoped, with no tenant
It gives nobody more reach. On an unscoped request confirm_bearer_tenant/2 drops every user
with a site, so only a master (a user whose site_id is nil) is recognised there — and a master's
token then works from any unknown host, not only from the master site's address. It passes over
that master-site-only rule, so leave it off unless an endpoint needs exactly that.
When false (default):
- Missing host headers return 400 "missing_host" error
- Unknown domains return 404 "domain_not_found" error
- Requests are halted with detailed error responses
- Enforces strict tenant isolation
Use Cases by Configuration
# Every request scoped to the site its host names
pipeline :tenant_api do
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug # allow_no_tenant: false
end
# A request from no known host goes on unscoped; only a master is recognised on it
pipeline :unscoped_api do
plug MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug, allow_no_tenant: true
end