Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ spark_locals_without_parens = [
allow_unregistered?: 1,
always_atomic?: 1,
always_select?: 1,
ancestor_attributes: 1,
args: 1,
argument: 1,
argument: 2,
Expand Down
1 change: 1 addition & 0 deletions documentation/dsls/DSL-Ash.Resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -4333,6 +4333,7 @@ end
|------|------|---------|------|
| [`strategy`](#multitenancy-strategy){: #multitenancy-strategy } | `:context \| :attribute` | `:context` | Determine if multitenancy is performed with attribute filters or using data layer features. |
| [`attribute`](#multitenancy-attribute){: #multitenancy-attribute } | `atom` | | If using the `attribute` strategy, the attribute to use, e.g `org_id` |
| [`ancestor_attributes`](#multitenancy-ancestor_attributes){: #multitenancy-ancestor_attributes } | `list(atom)` | `[]` | If using the `attribute` strategy with hierarchical tenants, the attributes identifying the tenant's ancestors, from broadest to narrowest, e.g `[:organization_id]` when `attribute` is `department_id` (or `[:organization_id, :department_id]` when `attribute` is `team_id`). Ancestor values are derived from the tenant via the `Ash.ToAncestorTenants` protocol, are applied to all tenant filters and creates, and data layers prefix indexes with them. |
| [`global?`](#multitenancy-global?){: #multitenancy-global? } | `boolean` | `false` | Whether or not the data may be accessed without setting a tenant. For example, with attribute multitenancy, this allows accessing without filtering by the tenant attribute. |
| [`parse_attribute`](#multitenancy-parse_attribute){: #multitenancy-parse_attribute } | `mfa` | `{Ash.Resource.Dsl, :identity, []}` | An mfa ({module, function, args}) pointing to a function that takes a tenant and returns the attribute value |
| [`tenant_from_attribute`](#multitenancy-tenant_from_attribute){: #multitenancy-tenant_from_attribute } | `mfa` | `{Ash.Resource.Dsl, :identity, []}` | An mfa ({module, function, args}) pointing to a function that takes an attribute value and returns the tenant. This is the inverse of `parse_attribute`. |
Expand Down
53 changes: 53 additions & 0 deletions documentation/topics/advanced/multitenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,59 @@ multitenancy do
end
```

### Hierarchical Tenants With Ancestor Attributes

For hierarchical tenants, like departments inside an organization, the tenant
stays a single value (the department), but `ancestor_attributes` can be
configured, ordered from broadest to narrowest. The ancestor values are
derived from the tenant via the `Ash.ToAncestorTenants` protocol, and are
applied everywhere the tenant attribute is: read filters, created records, and
upsert conflict targets. Data layers that generate unique indexes for
identities (like AshPostgres) prefix them with the ancestor attributes before
the tenant attribute, so one index serves both organization-level
(`organization_id`) and department-level (`organization_id, department_id`)
lookups. Deeper hierarchies just add more ancestors, e.g
`ancestor_attributes [:organization_id, :department_id]` for team-level
tenancy.

```elixir
defmodule MyApp.Customer do
use Ash.Resource, ...

multitenancy do
strategy :attribute
attribute :department_id
ancestor_attributes [:organization_id]
end
end

defmodule MyApp.Department do
use Ash.Resource, ...

defimpl Ash.ToTenant do
def to_tenant(department, _resource), do: department.id
end

defimpl Ash.ToAncestorTenants do
def to_ancestor_tenants(department, _resource), do: [department.organization_id]
end
end

MyApp.Customer
|> Ash.Query.for_read(:read, %{}, tenant: department)
|> Ash.read!()
```

`Ash.ToAncestorTenants` falls back to `[]` for values without an
implementation. If a resource configures `ancestor_attributes` and the derived
ancestors don't line up with it, the operation raises rather than silently
dropping the ancestor filters.

Tenants don't always arrive as structs: across serialization boundaries — a
background job's arguments, a session, an API header — a tenant is often
reduced to a plain id, which doesn't carry its ancestors. Rebuild a tenant
value that does (for example by looking the record up) before setting it.

### Transforming Tenant Values

You can provide the `parse_attribute` option if the tenant being set doesn't
Expand Down
12 changes: 6 additions & 6 deletions lib/ash/actions/create/bulk.ex
Original file line number Diff line number Diff line change
Expand Up @@ -893,11 +893,11 @@ defmodule Ash.Actions.Create.Bulk do
defp handle_attribute_multitenancy(changeset) do
if changeset.tenant &&
Ash.Resource.Info.multitenancy_strategy(changeset.resource) == :attribute do
attribute = Ash.Resource.Info.multitenancy_attribute(changeset.resource)
{m, f, a} = Ash.Resource.Info.multitenancy_parse_attribute(changeset.resource)
attribute_value = apply(m, f, [changeset.to_tenant | a])

Ash.Changeset.force_change_attribute(changeset, attribute, attribute_value)
changeset.resource
|> Ash.Resource.Info.multitenancy_attribute_values(changeset.tenant, changeset.to_tenant)
|> Enum.reduce(changeset, fn {attribute, value}, changeset ->
Ash.Changeset.force_change_attribute(changeset, attribute, value)
end)
else
changeset
end
Expand Down Expand Up @@ -1225,7 +1225,7 @@ defmodule Ash.Actions.Create.Bulk do

if !identity_record.all_tenants? &&
Ash.Resource.Info.multitenancy_strategy(resource) == :attribute do
Enum.uniq([Ash.Resource.Info.multitenancy_attribute(resource) | keys])
Enum.uniq(Ash.Resource.Info.multitenancy_attributes(resource) ++ keys)
else
keys
end
Expand Down
12 changes: 6 additions & 6 deletions lib/ash/actions/create/create.ex
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ defmodule Ash.Actions.Create do
keys
else
if Ash.Resource.Info.multitenancy_strategy(changeset.resource) == :attribute do
Enum.uniq([Ash.Resource.Info.multitenancy_attribute(changeset.resource) | keys])
Enum.uniq(Ash.Resource.Info.multitenancy_attributes(changeset.resource) ++ keys)
else
keys
end
Expand Down Expand Up @@ -697,11 +697,11 @@ defmodule Ash.Actions.Create do
defp handle_attribute_multitenancy(changeset) do
if changeset.tenant &&
Ash.Resource.Info.multitenancy_strategy(changeset.resource) == :attribute do
attribute = Ash.Resource.Info.multitenancy_attribute(changeset.resource)
{m, f, a} = Ash.Resource.Info.multitenancy_parse_attribute(changeset.resource)
attribute_value = apply(m, f, [changeset.to_tenant | a])

Ash.Changeset.force_change_attribute(changeset, attribute, attribute_value)
changeset.resource
|> Ash.Resource.Info.multitenancy_attribute_values(changeset.tenant, changeset.to_tenant)
|> Enum.reduce(changeset, fn {attribute, value}, changeset ->
Ash.Changeset.force_change_attribute(changeset, attribute, value)
end)
else
changeset
end
Expand Down
11 changes: 5 additions & 6 deletions lib/ash/actions/destroy/destroy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -401,12 +401,11 @@ defmodule Ash.Actions.Destroy do
defp handle_attribute_multitenancy(changeset) do
if changeset.tenant &&
Ash.Resource.Info.multitenancy_strategy(changeset.resource) == :attribute do
attribute = Ash.Resource.Info.multitenancy_attribute(changeset.resource)

{m, f, a} = Ash.Resource.Info.multitenancy_parse_attribute(changeset.resource)
attribute_value = apply(m, f, [changeset.to_tenant | a])

Ash.Changeset.filter(changeset, [{attribute, attribute_value}])
changeset.resource
|> Ash.Resource.Info.multitenancy_attribute_values(changeset.tenant, changeset.to_tenant)
|> Enum.reduce(changeset, fn {attribute, value}, changeset ->
Ash.Changeset.filter(changeset, [{attribute, value}])
end)
else
changeset
end
Expand Down
14 changes: 5 additions & 9 deletions lib/ash/actions/read/read.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2748,15 +2748,11 @@ defmodule Ash.Actions.Read do

defp handle_attribute_multitenancy(query) do
if query.tenant && Ash.Resource.Info.multitenancy_strategy(query.resource) == :attribute do
multitenancy_attribute = Ash.Resource.Info.multitenancy_attribute(query.resource)

if multitenancy_attribute do
{m, f, a} = Ash.Resource.Info.multitenancy_parse_attribute(query.resource)
attribute_value = apply(m, f, [query.to_tenant | a])
Ash.Query.filter(query, ^ref(multitenancy_attribute) == ^attribute_value)
else
query
end
query.resource
|> Ash.Resource.Info.multitenancy_attribute_values(query.tenant, query.to_tenant)
|> Enum.reduce(query, fn {attribute, value}, query ->
Ash.Query.filter(query, ^ref(attribute) == ^value)
end)
else
query
end
Expand Down
3 changes: 1 addition & 2 deletions lib/ash/actions/read/relationships.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2088,8 +2088,7 @@ defmodule Ash.Actions.Read.Relationships do
join_keys =
if query.tenant && source_query.tenant &&
Ash.Resource.Info.multitenancy_strategy(relationship.through) == :attribute do
attr = Ash.Resource.Info.multitenancy_attribute(relationship.through)
[attr | keys]
Ash.Resource.Info.multitenancy_attributes(relationship.through) ++ keys
else
keys
end
Expand Down
11 changes: 5 additions & 6 deletions lib/ash/actions/update/update.ex
Original file line number Diff line number Diff line change
Expand Up @@ -801,12 +801,11 @@ defmodule Ash.Actions.Update do
defp handle_attribute_multitenancy(changeset) do
if changeset.tenant &&
Ash.Resource.Info.multitenancy_strategy(changeset.resource) == :attribute do
attribute = Ash.Resource.Info.multitenancy_attribute(changeset.resource)

{m, f, a} = Ash.Resource.Info.multitenancy_parse_attribute(changeset.resource)
attribute_value = apply(m, f, [changeset.to_tenant | a])

Ash.Changeset.filter(changeset, expr(^ref(attribute) == ^attribute_value))
changeset.resource
|> Ash.Resource.Info.multitenancy_attribute_values(changeset.tenant, changeset.to_tenant)
|> Enum.reduce(changeset, fn {attribute, value}, changeset ->
Ash.Changeset.filter(changeset, expr(^ref(attribute) == ^value))
end)
else
changeset
end
Expand Down
4 changes: 2 additions & 2 deletions lib/ash/filter/filter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,8 @@ defmodule Ash.Filter do
end

{fields, value} ->
multitenancy_attribute = Ash.Resource.Info.multitenancy_attribute(resource)
fields = Enum.reject(fields, fn key -> key == multitenancy_attribute end)
multitenancy_attributes = Ash.Resource.Info.multitenancy_attributes(resource)
fields = Enum.reject(fields, fn key -> key in multitenancy_attributes end)

{keyval?, value} =
case fields do
Expand Down
3 changes: 2 additions & 1 deletion lib/ash/info/manifest/generator/resource_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ defmodule Ash.Info.Manifest.Generator.ResourceBuilder do
%{
strategy: strategy,
global?: Ash.Resource.Info.multitenancy_global?(resource),
attribute: Ash.Resource.Info.multitenancy_attribute(resource)
attribute: Ash.Resource.Info.multitenancy_attribute(resource),
ancestor_attributes: Ash.Resource.Info.multitenancy_ancestor_attributes(resource)
}
else
nil
Expand Down
3 changes: 2 additions & 1 deletion lib/ash/info/manifest/json_serializer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ defmodule Ash.Info.Manifest.JsonSerializer do
%{
"strategy" => to_string(mt.strategy),
"global" => mt.global?,
"attribute" => serialize_atom(mt.attribute)
"attribute" => serialize_atom(mt.attribute),
"ancestor_attributes" => Enum.map(mt.ancestor_attributes || [], &serialize_atom/1)
}
end

Expand Down
9 changes: 8 additions & 1 deletion lib/ash/info/manifest/resource.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ defmodule Ash.Info.Manifest.Resource do
fields: %{atom() => Ash.Info.Manifest.Field.t()},
relationships: %{atom() => Ash.Info.Manifest.Relationship.t()},
identities: %{atom() => %{keys: [atom()]}},
multitenancy: %{strategy: atom(), global?: boolean(), attribute: atom()} | nil,
multitenancy:
%{
strategy: atom(),
global?: boolean(),
attribute: atom(),
ancestor_attributes: [atom()]
}
| nil,
custom: map()
}

Expand Down
7 changes: 7 additions & 0 deletions lib/ash/resource/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1704,6 +1704,13 @@ defmodule Ash.Resource.Dsl do
If using the `attribute` strategy, the attribute to use, e.g `org_id`
"""
],
ancestor_attributes: [
type: {:list, :atom},
default: [],
doc: """
If using the `attribute` strategy with hierarchical tenants, the attributes identifying the tenant's ancestors, from broadest to narrowest, e.g `[:organization_id]` when `attribute` is `department_id` (or `[:organization_id, :department_id]` when `attribute` is `team_id`). Ancestor values are derived from the tenant via the `Ash.ToAncestorTenants` protocol, are applied to all tenant filters and creates, and data layers prefix indexes with them.
"""
],
global?: [
type: :boolean,
doc: """
Expand Down
72 changes: 72 additions & 0 deletions lib/ash/resource/info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,78 @@ defmodule Ash.Resource.Info do
Spark.Dsl.Extension.get_opt(resource, [:multitenancy], :attribute, nil)
end

@doc "The multitenancy ancestor attributes for a resource, for hierarchical tenants"
@spec multitenancy_ancestor_attributes(Spark.Dsl.t() | Ash.Resource.t()) :: list(atom)
def multitenancy_ancestor_attributes(resource) do
Spark.Dsl.Extension.get_opt(resource, [:multitenancy], :ancestor_attributes, [])
end

@doc """
The multitenancy attribute(s) for a resource, as a list.

Ancestor attributes, when configured, come first, matching the broadest to
narrowest scoping order used for index prefixes.
"""
@spec multitenancy_attributes(Spark.Dsl.t() | Ash.Resource.t()) :: list(atom)
def multitenancy_attributes(resource) do
multitenancy_ancestor_attributes(resource) ++ List.wrap(multitenancy_attribute(resource))
end

@doc """
Pairs each multitenancy attribute with its value for the given tenant.

The tenant attribute value is parsed from `to_tenant` (the
`Ash.ToTenant`-converted tenant) with `parse_attribute`. When
`ancestor_attributes` is configured, the ancestor values are derived from
the original `tenant` via the `Ash.ToAncestorTenants` protocol and paired
positionally; their pairs come first. Raises if the derived ancestors don't
line up with `ancestor_attributes`, to avoid silently dropping ancestor
filters.
"""
@spec multitenancy_attribute_values(Spark.Dsl.t() | Ash.Resource.t(), term, term) ::
list({atom, term})
def multitenancy_attribute_values(resource, tenant, to_tenant) do
attribute_values =
case multitenancy_attribute(resource) do
nil ->
[]

attribute ->
{m, f, a} = multitenancy_parse_attribute(resource)
[{attribute, apply(m, f, [to_tenant | a])}]
end

ancestor_attribute_values =
case multitenancy_ancestor_attributes(resource) do
[] ->
[]

ancestor_attributes ->
resource_module = Spark.Dsl.Extension.get_persisted(resource, :module, resource)

ancestors = Ash.ToAncestorTenants.to_ancestor_tenants(tenant, resource_module)

if !is_list(ancestors) || length(ancestors) != length(ancestor_attributes) ||
Enum.any?(ancestors, &is_nil/1) do
raise ArgumentError, """
#{inspect(resource_module)} configures `ancestor_attributes #{inspect(ancestor_attributes)}`, but the ancestors
derived from #{inspect(tenant)} via `Ash.ToAncestorTenants` were #{inspect(ancestors)}.

Expected a list of #{length(ancestor_attributes)} non-nil values, ordered from
broadest to narrowest. Implement `Ash.ToAncestorTenants` for the tenant value being
set, or set a tenant that carries its ancestors. Prefer reading ancestor ids from the
tenant record's own attributes — when every level of the hierarchy configures
`ancestor_attributes`, each tenant record already stores all of its ancestors' ids —
rather than from relationships that may not be loaded.
"""
end

Enum.zip(ancestor_attributes, ancestors)
end

ancestor_attribute_values ++ attribute_values
end

@doc "The function to parse the tenant from the attribute"
@spec multitenancy_parse_attribute(Spark.Dsl.t() | Ash.Resource.t()) :: {atom, atom, list(any)}
def multitenancy_parse_attribute(resource) do
Expand Down
Loading
Loading