diff --git a/.formatter.exs b/.formatter.exs index f83863403..03ace079c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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, diff --git a/documentation/dsls/DSL-Ash.Resource.md b/documentation/dsls/DSL-Ash.Resource.md index 8ec8c39d8..76bde68b4 100644 --- a/documentation/dsls/DSL-Ash.Resource.md +++ b/documentation/dsls/DSL-Ash.Resource.md @@ -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`. | diff --git a/documentation/topics/advanced/multitenancy.md b/documentation/topics/advanced/multitenancy.md index 2688edfe6..043a3558b 100644 --- a/documentation/topics/advanced/multitenancy.md +++ b/documentation/topics/advanced/multitenancy.md @@ -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 diff --git a/lib/ash/actions/create/bulk.ex b/lib/ash/actions/create/bulk.ex index 4ab563fe0..8f37c9ce5 100644 --- a/lib/ash/actions/create/bulk.ex +++ b/lib/ash/actions/create/bulk.ex @@ -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 @@ -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 diff --git a/lib/ash/actions/create/create.ex b/lib/ash/actions/create/create.ex index d09cd3c93..5aa1c309e 100644 --- a/lib/ash/actions/create/create.ex +++ b/lib/ash/actions/create/create.ex @@ -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 @@ -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 diff --git a/lib/ash/actions/destroy/destroy.ex b/lib/ash/actions/destroy/destroy.ex index 1b1b5af0b..f9860e0ee 100644 --- a/lib/ash/actions/destroy/destroy.ex +++ b/lib/ash/actions/destroy/destroy.ex @@ -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 diff --git a/lib/ash/actions/read/read.ex b/lib/ash/actions/read/read.ex index d13dd78f6..1838f0bae 100644 --- a/lib/ash/actions/read/read.ex +++ b/lib/ash/actions/read/read.ex @@ -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 diff --git a/lib/ash/actions/read/relationships.ex b/lib/ash/actions/read/relationships.ex index 0ef577cac..e49331a4f 100644 --- a/lib/ash/actions/read/relationships.ex +++ b/lib/ash/actions/read/relationships.ex @@ -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 diff --git a/lib/ash/actions/update/update.ex b/lib/ash/actions/update/update.ex index d1e7f105b..debf38107 100644 --- a/lib/ash/actions/update/update.ex +++ b/lib/ash/actions/update/update.ex @@ -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 diff --git a/lib/ash/filter/filter.ex b/lib/ash/filter/filter.ex index d7d8e2599..50d9d1717 100644 --- a/lib/ash/filter/filter.ex +++ b/lib/ash/filter/filter.ex @@ -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 diff --git a/lib/ash/info/manifest/generator/resource_builder.ex b/lib/ash/info/manifest/generator/resource_builder.ex index 25b10a028..9aa376cd8 100644 --- a/lib/ash/info/manifest/generator/resource_builder.ex +++ b/lib/ash/info/manifest/generator/resource_builder.ex @@ -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 diff --git a/lib/ash/info/manifest/json_serializer.ex b/lib/ash/info/manifest/json_serializer.ex index 59e5d39d3..18ba5f95d 100644 --- a/lib/ash/info/manifest/json_serializer.ex +++ b/lib/ash/info/manifest/json_serializer.ex @@ -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 diff --git a/lib/ash/info/manifest/resource.ex b/lib/ash/info/manifest/resource.ex index 9761e51e6..bc26f9a18 100644 --- a/lib/ash/info/manifest/resource.ex +++ b/lib/ash/info/manifest/resource.ex @@ -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() } diff --git a/lib/ash/resource/dsl.ex b/lib/ash/resource/dsl.ex index 2030bb0d7..ecb0a5b97 100644 --- a/lib/ash/resource/dsl.ex +++ b/lib/ash/resource/dsl.ex @@ -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: """ diff --git a/lib/ash/resource/info.ex b/lib/ash/resource/info.ex index 63995ad86..307d3300b 100644 --- a/lib/ash/resource/info.ex +++ b/lib/ash/resource/info.ex @@ -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 diff --git a/lib/ash/resource/verifiers/validate_multitenancy.ex b/lib/ash/resource/verifiers/validate_multitenancy.ex index 356e06f55..32348d141 100644 --- a/lib/ash/resource/verifiers/validate_multitenancy.ex +++ b/lib/ash/resource/verifiers/validate_multitenancy.ex @@ -17,10 +17,22 @@ defmodule Ash.Resource.Verifiers.ValidateMultitenancy do def verify(dsl_state) do strategy = Verifier.get_option(dsl_state, [:multitenancy], :strategy) attribute = Verifier.get_option(dsl_state, [:multitenancy], :attribute) + + ancestor_attributes = + Verifier.get_option(dsl_state, [:multitenancy], :ancestor_attributes) || [] + attributes = Verifier.get_entities(dsl_state, [:attributes]) resource = Verifier.get_persisted(dsl_state, :module) data_layer = Verifier.get_persisted(dsl_state, :data_layer) + missing_ancestor_attributes = + Enum.reject( + ancestor_attributes, + fn ancestor_attribute -> + Enum.any?(attributes, &(&1.name == ancestor_attribute)) + end + ) + cond do strategy == :context && data_layer && not Ash.DataLayer.can?(data_layer, resource, :multitenancy) -> @@ -61,6 +73,46 @@ defmodule Ash.Resource.Verifiers.ValidateMultitenancy do location: attribute_anno )} + ancestor_attributes != [] && strategy != :attribute -> + ancestor_attributes_anno = + Extension.get_opt_anno(dsl_state, [:multitenancy], :ancestor_attributes) + + {:error, + DslError.exception( + module: resource, + path: [:multitenancy, :ancestor_attributes], + message: "The `ancestor_attributes` option requires the `:attribute` strategy", + location: ancestor_attributes_anno + )} + + ancestor_attributes != [] && + Enum.uniq(ancestor_attributes ++ [attribute]) != ancestor_attributes ++ [attribute] -> + ancestor_attributes_anno = + Extension.get_opt_anno(dsl_state, [:multitenancy], :ancestor_attributes) + + {:error, + DslError.exception( + module: resource, + path: [:multitenancy, :ancestor_attributes], + message: + "The `ancestor_attributes` #{inspect(ancestor_attributes)} and the `attribute` #{attribute} contain duplicates. " <> + "`ancestor_attributes` lists only the levels above the tenant attribute, not the tenant attribute itself.", + location: ancestor_attributes_anno + )} + + missing_ancestor_attributes != [] -> + ancestor_attributes_anno = + Extension.get_opt_anno(dsl_state, [:multitenancy], :ancestor_attributes) + + {:error, + DslError.exception( + module: resource, + path: [:multitenancy, :ancestor_attributes], + message: + "Attribute #{Enum.map_join(missing_ancestor_attributes, ", ", &to_string/1)} used in multitenancy configuration does not exist", + location: ancestor_attributes_anno + )} + true -> :ok end diff --git a/lib/ash/seed.ex b/lib/ash/seed.ex index f3bf773a2..316d10588 100644 --- a/lib/ash/seed.ex +++ b/lib/ash/seed.ex @@ -538,11 +538,11 @@ defmodule Ash.Seed do defp maybe_set_attribute_tenant(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 diff --git a/lib/ash/to_ancestor_tenants.ex b/lib/ash/to_ancestor_tenants.ex new file mode 100644 index 000000000..84e9377c0 --- /dev/null +++ b/lib/ash/to_ancestor_tenants.ex @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defprotocol Ash.ToAncestorTenants do + @moduledoc """ + Derives the ancestor tenant values from a tenant, for resources using the + `ancestor_attributes` multitenancy option. + + Returns the ancestors' attribute values ordered from broadest to narrowest, + matching the resource's `ancestor_attributes`. For example, with + department-level tenancy inside an organization: + + ```elixir + 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 + ``` + + A deeper hierarchy just returns more ancestors, e.g + `[team.organization_id, team.department_id]` for `ancestor_attributes [:organization_id, :department_id]` + with team-level tenancy. + + The default implementation returns `[]`, so tenants without an + implementation are unaffected. When a resource configures + `ancestor_attributes` and the ancestors don't line up with it, the operation + raises instead of silently dropping the ancestor filters. + """ + + @fallback_to_any true + + @type t :: term() + + @spec to_ancestor_tenants(t, Ash.Resource.t()) :: list(term) + def to_ancestor_tenants(value, resource) +end + +defimpl Ash.ToAncestorTenants, for: Any do + def to_ancestor_tenants(_value, _resource), do: [] +end diff --git a/test/actions/multitenancy_ancestor_attributes_test.exs b/test/actions/multitenancy_ancestor_attributes_test.exs new file mode 100644 index 000000000..844dd4427 --- /dev/null +++ b/test/actions/multitenancy_ancestor_attributes_test.exs @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: 2019 ash contributors +# +# SPDX-License-Identifier: MIT + +defmodule Ash.Actions.MultitenancyAncestorAttributesTest do + use ExUnit.Case, async: true + + alias Ash.Test.Domain, as: Domain + + defmodule DepartmentTenant do + @moduledoc false + defstruct [:id, :organization_id] + + 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 + + defmodule TeamTenant do + @moduledoc false + defstruct [:id, :organization_id, :department_id] + + defimpl Ash.ToTenant do + def to_tenant(team, _resource), do: team.id + end + + defimpl Ash.ToAncestorTenants do + def to_ancestor_tenants(team, _resource), do: [team.organization_id, team.department_id] + end + end + + defmodule Customer do + @moduledoc false + use Ash.Resource, + domain: Domain, + data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end + + multitenancy do + strategy(:attribute) + attribute(:department_id) + ancestor_attributes([:organization_id]) + end + + actions do + default_accept :* + defaults [:read, :destroy, create: :*, update: :*] + end + + attributes do + uuid_primary_key :id + + attribute :name, :string do + public?(true) + end + + attribute :organization_id, :uuid do + public?(true) + end + + attribute :department_id, :uuid do + public?(true) + end + end + end + + defmodule Task do + @moduledoc false + use Ash.Resource, + domain: Domain, + data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end + + multitenancy do + strategy(:attribute) + attribute(:team_id) + ancestor_attributes([:organization_id, :department_id]) + end + + actions do + default_accept :* + defaults [:read, :destroy, create: :*, update: :*] + end + + attributes do + uuid_primary_key :id + + attribute :name, :string do + public?(true) + end + + attribute :organization_id, :uuid do + public?(true) + end + + attribute :department_id, :uuid do + public?(true) + end + + attribute :team_id, :uuid do + public?(true) + end + end + end + + setup do + organization_id = Ash.UUID.generate() + other_organization_id = Ash.UUID.generate() + department_a = %DepartmentTenant{id: Ash.UUID.generate(), organization_id: organization_id} + + %{ + organization_id: organization_id, + other_organization_id: other_organization_id, + department_a: department_a + } + end + + describe "ancestor derivation" do + test "a tenant that can't derive its ancestors raises instead of silently dropping the ancestor filters" do + # A bare id has no Ash.ToAncestorTenants implementation, so without the + # raise this read would succeed with only the department filter applied. + bare_department_id = Ash.UUID.generate() + + assert_raise Ash.Error.Unknown, ~r/Ash.ToAncestorTenants/, fn -> + Ash.read!(Customer, tenant: bare_department_id) + end + end + end + + describe "create" do + test "create stamps derived ancestor attributes, not just the tenant attribute", %{ + organization_id: organization_id, + department_a: department_a + } do + customer = + Customer + |> Ash.Changeset.for_create(:create, %{name: "customer"}, tenant: department_a) + |> Ash.create!() + + assert customer.department_id == department_a.id + assert customer.organization_id == organization_id + + team = %TeamTenant{ + id: Ash.UUID.generate(), + organization_id: organization_id, + department_id: department_a.id + } + + task = + Task + |> Ash.Changeset.for_create(:create, %{name: "task"}, tenant: team) + |> Ash.create!() + + assert task.team_id == team.id + assert task.department_id == department_a.id + assert task.organization_id == organization_id + end + end + + describe "read" do + test "a tenant with the same department id but another organization reads nothing", %{ + other_organization_id: other_organization_id, + department_a: department_a + } do + customer = + Customer + |> Ash.Changeset.for_create(:create, %{name: "customer"}, tenant: department_a) + |> Ash.create!() + + assert [%{id: read_id}] = Customer |> Ash.read!(tenant: department_a) + assert read_id == customer.id + + # The department filter alone matches this row; only the ancestor + # filter can exclude it + wrong_organization_department = %DepartmentTenant{ + id: department_a.id, + organization_id: other_organization_id + } + + assert [] = Customer |> Ash.read!(tenant: wrong_organization_department) + end + end + + describe "update" do + test "update can't reach a row whose ancestors don't match the tenant", %{ + other_organization_id: other_organization_id, + department_a: department_a + } do + customer = + Customer + |> Ash.Changeset.for_create(:create, %{name: "before"}, tenant: department_a) + |> Ash.create!() + + wrong_organization_department = %DepartmentTenant{ + id: department_a.id, + organization_id: other_organization_id + } + + assert {:error, _} = + customer + |> Ash.Changeset.for_update(:update, %{name: "after"}, + tenant: wrong_organization_department + ) + |> Ash.update() + + assert [%{name: "before"}] = Customer |> Ash.read!(tenant: department_a) + end + end +end diff --git a/test/resource/validate_multitenancy_test.exs b/test/resource/validate_multitenancy_test.exs index e3fb7ee69..de2eca5f2 100644 --- a/test/resource/validate_multitenancy_test.exs +++ b/test/resource/validate_multitenancy_test.exs @@ -49,6 +49,103 @@ defmodule Ash.Test.Resource.ValidateMultitenancyTest do assert error.message =~ "does not exist" end + test "ancestor_attributes with a non-existent attribute produces an error" do + error = + assert_dsl_error %Spark.Error.DslError{path: [:multitenancy, :ancestor_attributes]} do + defmodule Elixir.MissingAncestorAttributePost do + @moduledoc false + use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key :id + attribute :department_id, :uuid, public?: true + end + + multitenancy do + strategy :attribute + attribute :department_id + ancestor_attributes([:organization_id]) + end + end + end + + assert error.message =~ "does not exist" + end + + test "ancestor_attributes listing the whole hierarchy including the attribute produces an error" do + error = + assert_dsl_error %Spark.Error.DslError{path: [:multitenancy, :ancestor_attributes]} do + defmodule Elixir.WholeHierarchyAncestorAttributesPost do + @moduledoc false + use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key :id + attribute :organization_id, :uuid, public?: true + attribute :department_id, :uuid, public?: true + end + + multitenancy do + strategy :attribute + attribute :department_id + ancestor_attributes([:organization_id, :department_id]) + end + end + end + + assert error.message =~ "not the tenant attribute itself" + end + + test "ancestor_attributes without the attribute strategy produces an error" do + error = + assert_dsl_error %Spark.Error.DslError{path: [:multitenancy, :ancestor_attributes]} do + defmodule Elixir.ContextAncestorAttributePost do + @moduledoc false + use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Mnesia + + attributes do + uuid_primary_key :id + attribute :organization_id, :uuid, public?: true + end + + multitenancy do + strategy :context + ancestor_attributes([:organization_id]) + end + end + end + + assert error.message =~ "requires the `:attribute` strategy" + end + + test "attribute strategy with valid ancestor_attributes is valid" do + refute_dsl_errors do + defmodule Elixir.ValidAncestorAttributesPost do + @moduledoc false + use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key :id + attribute :organization_id, :uuid, public?: true + attribute :department_id, :uuid, public?: true + attribute :team_id, :uuid, public?: true + end + + multitenancy do + strategy :attribute + attribute :team_id + ancestor_attributes([:organization_id, :department_id]) + end + end + end + + assert Ash.Resource.Info.multitenancy_ancestor_attributes(Elixir.ValidAncestorAttributesPost) == + [:organization_id, :department_id] + + assert Ash.Resource.Info.multitenancy_attributes(Elixir.ValidAncestorAttributesPost) == + [:organization_id, :department_id, :team_id] + end + test "attribute strategy with a real attribute is valid" do refute_dsl_errors do defmodule Elixir.ValidMultitenancyPost do