AshScim is multi-tenancy-agnostic. The router threads whatever tenant
your upstream pipeline placed on the conn into every Ash call it makes —
both Ash.Query.set_tenant/2 / Ash.Changeset.set_tenant/2 and the
:tenant option to Ash.read, Ash.get, Ash.create, Ash.update,
Ash.destroy, Ash.load, and Ash.bulk_destroy. Because of that, both
of Ash's multitenancy strategies — attribute-based and context-based —
work end to end.
The router reads the tenant via
Ash.PlugHelpers.get_tenant/1.
You set it upstream — typically in a Phoenix plug that runs before the
SCIM router, or directly inside your custom auth implementation:
defmodule MyAppWeb.ScimTenantPlug do
@behaviour Plug
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
tenant = derive_tenant(conn)
Ash.PlugHelpers.set_tenant(conn, tenant)
end
defp derive_tenant(conn) do
# Examples:
# - From the JWT claim: conn.assigns[:current_user].tenant_id
# - From the URL prefix: conn.path_info |> hd()
# - From a custom header: Plug.Conn.get_req_header(conn, "x-tenant") |> hd()
# ...
end
endWire it into your endpoint just before the SCIM dispatch:
plug MyAppWeb.ScimTenantPlug # sets the tenant
plug :scim_dispatch # forwards to AshScim.RouterThe router itself never makes a tenancy policy decision — it just
honours whatever you set. If you don't set anything, all Ash calls run
without a tenant, which on resources configured with global?: false
will fail at the data layer (the safer default).
For resources where the tenant lives in a column on the resource itself:
defmodule MyApp.Accounts.User do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication, AshScim.User]
multitenancy do
strategy :attribute
attribute :tenant_id
global? false
end
attributes do
uuid_primary_key :id
attribute :tenant_id, :string, allow_nil?: false # not public; auto-set
attribute :email, :ci_string, allow_nil?: false, public?: true
# ...
end
scim do
map :userName, attribute: :email
# ...
end
# ... your authentication block, actions, etc...
endWhen the router calls Ash.read/2 with tenant: "acme", Ash applies
tenant_id == "acme" automatically. Inbound SCIM filters can't escape
the tenant — they're combined with the tenancy filter at the data layer.
A GET /Users/:id for a record belonging to a different tenant returns
404 (it's invisible to the request).
For multi-schema or multi-database setups:
multitenancy do
strategy :context
global? false
endThe router's tenant: plumbing is identical — Ash interprets it as the
schema name (or whatever the data layer's set_tenant/3 callback does).
For AshPostgres, this typically means the request operates against
SET search_path = "tenant_xyz" for its duration.
Every entry point honours the tenant:
GET /Usersand?filter=…apply tenant scoping at the query level.GET /Users/:idreturns 404 (not 403) if the record belongs to another tenant — to avoid leaking the existence of cross-tenant records.POST /Userswrites the new record under the request's tenant.PUT/PATCH/DELETEonly act on records that belong to the request's tenant; cross-tenant attempts 404.- PATCH
add path: "members"andreplace path: "members"apply the same tenant to the related-row writes viamanage_relationship. - PATCH
remove path: "members[…]"runs itsbulk_destroyquery under the same tenant.
A common multi-tenant SCIM use case: each customer plugs in their own IdP, with their own bearer token. Two patterns:
1. Tenant from the bearer token. Your custom
AshScim.Auth implementation looks up which tenant the token belongs
to, sets Ash.PlugHelpers.set_tenant/2 on the conn, and returns a
service-account user as the actor:
defmodule MyApp.Scim.PerTenantAuth do
@behaviour AshScim.Auth
@impl true
def authenticate(conn, _opts) do
with ["Bearer " <> token] <- Plug.Conn.get_req_header(conn, "authorization"),
{:ok, tenant, service_user} <- MyApp.Scim.Tokens.verify(token) do
conn = Ash.PlugHelpers.set_tenant(conn, tenant)
{:ok, conn, service_user}
else
_ -> {:error, "invalid token"}
end
end
end2. Tenant from the URL. Your endpoint plug strips a tenant prefix out of the path before forwarding to the SCIM router:
plug :scim_dispatch
defp scim_dispatch(%Plug.Conn{path_info: ["scim", "v2", tenant | rest]} = conn, _opts) do
conn =
%{conn | path_info: rest, script_name: conn.script_name ++ ["scim", "v2", tenant]}
|> Ash.PlugHelpers.set_tenant(tenant)
conn
|> MyAppWeb.ScimRouter.call(MyAppWeb.ScimRouter.init([]))
|> Plug.Conn.halt()
end
defp scim_dispatch(conn, _opts), do: connEither pattern is fine — pick the one that matches how your IdPs are configured.