MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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-tenant leaves 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 & Assigns

Basic 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
end

Advanced 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
end

The 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.com

Every 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/site

An 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 for x-no-tenant or ?no_tenant=true and 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.

Confirms the tenant call/2 chose, once :load_from_bearer has read the token's user.

Initializes the plug with configuration options.

Functions

call(conn, opts)

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/response
  • opts - Configuration options from init/1

Return Value

  • Plug.Conn - Updated connection with tenant context or error response

Processing Pipeline

  1. Host Extraction: Extracts domain from Host header or conn.host (tests)
  2. Site Lookup: Queries SiteState cache for site configuration
  3. Site Validation: Ensures site is active and available
  4. Tenant Override Check: Handles explicit no-tenant requests
  5. Context Setting: Configures Ash tenant context and response headers
  6. Assignment: Sets current_site assign 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 tenant

Integration 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
end

Monitoring 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
end

Security 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

confirm_bearer_tenant(conn, opts)

@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
end

An 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.

init(opts)

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 to false)

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: true

Configuration 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