Skip to content

Commit 8cfa351

Browse files
authored
feat(calculations): Add multitenancy bypass options to calculations (#2552)
Allow calculations to declare multitenancy: :bypass | :bypass_all | :allow_global | :enforce in the DSL, matching the pattern already used by actions and aggregates. The bypass flag propagates through source_context so module calculations that load dependencies can cross tenant boundaries.
1 parent c65d641 commit 8cfa351

File tree

5 files changed

+253
-20
lines changed

5 files changed

+253
-20
lines changed

documentation/dsls/DSL-Ash.Resource.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3768,6 +3768,7 @@ end
37683768
| [`allow_nil?`](#calculations-calculate-allow_nil?){: #calculations-calculate-allow_nil? } | `boolean` | `true` | Whether or not the calculation can return nil. |
37693769
| [`filterable?`](#calculations-calculate-filterable?){: #calculations-calculate-filterable? } | `boolean \| :simple_equality` | `true` | Whether or not the calculation should be usable in filters. |
37703770
| [`sortable?`](#calculations-calculate-sortable?){: #calculations-calculate-sortable? } | `boolean` | `true` | Whether or not the calculation can be referenced in sorts. |
3771+
| [`multitenancy`](#calculations-calculate-multitenancy){: #calculations-calculate-multitenancy } | `:enforce \| :allow_global \| :bypass \| :bypass_all` | | Configures multitenancy behavior for the calculation. `:enforce` requires a tenant to be set (the default behavior), `:allow_global` allows using this calculation both with and without a tenant, `:bypass` completely ignores the tenant even if it's set, `:bypass_all` like `:bypass` but also bypasses the tenancy requirement for nested resources. |
37713772

37723773

37733774
### calculations.calculate.argument

lib/ash/actions/read/read.ex

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2811,7 +2811,8 @@ defmodule Ash.Actions.Read do
28112811
end
28122812

28132813
defp handle_aggregate_multitenancy(query) do
2814-
Enum.reduce_while(query.aggregates, {:ok, %{}}, fn {key, aggregate}, {:ok, acc} ->
2814+
query.aggregates
2815+
|> Enum.reduce_while({:ok, %{}}, fn {key, aggregate}, {:ok, acc} ->
28152816
with aggregate_query <-
28162817
apply_aggregate_tenant(aggregate.query, query.tenant, aggregate.multitenancy),
28172818
:ok <- validate_aggregate_multitenancy(aggregate),
@@ -2823,8 +2824,16 @@ defmodule Ash.Actions.Read do
28232824
end
28242825
end)
28252826
|> case do
2826-
{:ok, aggregates} -> {:ok, %{query | aggregates: aggregates}}
2827-
{:error, error} -> {:error, error}
2827+
{:ok, aggregates} ->
2828+
calculations =
2829+
Map.new(query.calculations, fn {key, calculation} ->
2830+
{key, apply_calculation_tenant(calculation)}
2831+
end)
2832+
2833+
{:ok, %{query | aggregates: aggregates, calculations: calculations}}
2834+
2835+
{:error, error} ->
2836+
{:error, error}
28282837
end
28292838
end
28302839

@@ -2882,6 +2891,22 @@ defmodule Ash.Actions.Read do
28822891
end
28832892
end
28842893

2894+
defp apply_calculation_tenant(%{multitenancy: multitenancy} = calculation)
2895+
when multitenancy in [:bypass, :bypass_all, :allow_global] do
2896+
mode = if multitenancy == :allow_global, do: :allow_global, else: :bypass_all
2897+
2898+
updated_context =
2899+
Map.update!(calculation.context, :source_context, fn source_context ->
2900+
Ash.Helpers.deep_merge_maps(source_context, %{
2901+
private: %{multitenancy: mode}
2902+
})
2903+
end)
2904+
2905+
%{calculation | context: updated_context}
2906+
end
2907+
2908+
defp apply_calculation_tenant(calculation), do: calculation
2909+
28852910
defp add_tenant(data, query) do
28862911
if Ash.Resource.Info.multitenancy_strategy(query.resource) do
28872912
Enum.map(data, fn item ->

lib/ash/query/calculation.ex

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ defmodule Ash.Query.Calculation do
1313
:type,
1414
:constraints,
1515
:calc_name,
16+
:multitenancy,
1617
context: %{},
1718
required_loads: [],
1819
select: [],
@@ -216,22 +217,24 @@ defmodule Ash.Query.Calculation do
216217

217218
with {:ok, opts} <- FromResourceOpts.validate(opts),
218219
{:ok, args} <-
219-
Ash.Query.validate_calculation_arguments(resource_calculation, opts.args) do
220-
new(
221-
name,
222-
module,
223-
calc_opts,
224-
resource_calculation.type,
225-
resource_calculation.constraints,
226-
arguments: args,
227-
async?: resource_calculation.async?,
228-
filterable?: resource_calculation.filterable?,
229-
sortable?: resource_calculation.sortable?,
230-
sensitive?: resource_calculation.sensitive?,
231-
load: resource_calculation.load,
232-
source_context: opts.source_context,
233-
calc_name: resource_calculation.name
234-
)
220+
Ash.Query.validate_calculation_arguments(resource_calculation, opts.args),
221+
{:ok, calculation} <-
222+
new(
223+
name,
224+
module,
225+
calc_opts,
226+
resource_calculation.type,
227+
resource_calculation.constraints,
228+
arguments: args,
229+
async?: resource_calculation.async?,
230+
filterable?: resource_calculation.filterable?,
231+
sortable?: resource_calculation.sortable?,
232+
sensitive?: resource_calculation.sensitive?,
233+
load: resource_calculation.load,
234+
source_context: opts.source_context,
235+
calc_name: resource_calculation.name
236+
) do
237+
{:ok, %{calculation | multitenancy: resource_calculation.multitenancy}}
235238
end
236239
end
237240

lib/ash/resource/calculation/calculation.ex

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ defmodule Ash.Resource.Calculation do
1616
description: nil,
1717
filterable?: true,
1818
sortable?: true,
19+
sensitive?: false,
20+
multitenancy: nil,
1921
load: [],
2022
name: nil,
2123
public?: false,
2224
async?: false,
23-
sensitive?: false,
2425
type: nil,
2526
__spark_metadata__: nil
2627

@@ -91,6 +92,16 @@ defmodule Ash.Resource.Calculation do
9192
doc: """
9293
Whether or not the calculation can be referenced in sorts.
9394
"""
95+
],
96+
multitenancy: [
97+
type: {:in, [:enforce, :allow_global, :bypass, :bypass_all]},
98+
doc: """
99+
Configures multitenancy behavior for the calculation.
100+
`:enforce` requires a tenant to be set (the default behavior),
101+
`:allow_global` allows using this calculation both with and without a tenant,
102+
`:bypass` completely ignores the tenant even if it's set,
103+
`:bypass_all` like `:bypass` but also bypasses the tenancy requirement for nested resources.
104+
"""
94105
]
95106
]
96107

@@ -136,6 +147,8 @@ defmodule Ash.Resource.Calculation do
136147
filterable?: boolean,
137148
load: keyword,
138149
sortable?: boolean,
150+
sensitive?: boolean,
151+
multitenancy: nil | :enforce | :allow_global | :bypass | :bypass_all,
139152
name: atom(),
140153
public?: boolean,
141154
type: nil | Ash.Type.t(),

test/actions/multitenancy_test.exs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,24 @@ defmodule Ash.Actions.MultitenancyTest do
116116
end
117117
end
118118

119+
calculations do
120+
calculate :name_lower, :string, expr(string_downcase(name)), public?: true
121+
122+
calculate :name_lower_bypass, :string, expr(string_downcase(name)),
123+
public?: true,
124+
multitenancy: :bypass
125+
126+
calculate :name_lower_allow_global, :string, expr(string_downcase(name)),
127+
public?: true,
128+
multitenancy: :allow_global
129+
130+
calculate :cross_tenant_post_count,
131+
:integer,
132+
Ash.Actions.MultitenancyTest.CrossTenantPostCount,
133+
public?: true,
134+
multitenancy: :bypass
135+
end
136+
119137
relationships do
120138
has_many :posts, Ash.Actions.MultitenancyTest.Post,
121139
destination_attribute: :author_id,
@@ -261,6 +279,12 @@ defmodule Ash.Actions.MultitenancyTest do
261279
end
262280
end
263281

282+
calculations do
283+
calculate :name_lower_bypass, :string, expr(string_downcase(name)),
284+
public?: true,
285+
multitenancy: :bypass
286+
end
287+
264288
relationships do
265289
belongs_to :commenter, User do
266290
public?(true)
@@ -295,6 +319,20 @@ defmodule Ash.Actions.MultitenancyTest do
295319
end
296320
end
297321

322+
defmodule CrossTenantPostCount do
323+
use Ash.Resource.Calculation
324+
325+
def load(_, _, _) do
326+
[:posts]
327+
end
328+
329+
def calculate(records, _, _) do
330+
Enum.map(records, fn record ->
331+
length(record.posts || [])
332+
end)
333+
end
334+
end
335+
298336
defmodule OtherThingName do
299337
use Ash.Resource.Calculation
300338

@@ -1573,4 +1611,157 @@ defmodule Ash.Actions.MultitenancyTest do
15731611
assert Enum.empty?(User |> Ash.Query.for_read(:bypass_tenant) |> Ash.read!())
15741612
end
15751613
end
1614+
1615+
describe "calculation multitenancy bypass" do
1616+
setup do
1617+
tenant1 = Ash.UUID.generate()
1618+
tenant2 = Ash.UUID.generate()
1619+
1620+
user1 =
1621+
User
1622+
|> Ash.Changeset.for_create(:create, %{name: "alice"}, tenant: tenant1)
1623+
|> Ash.create!()
1624+
1625+
user2 =
1626+
User
1627+
|> Ash.Changeset.for_create(:create, %{name: "bob"}, tenant: tenant2)
1628+
|> Ash.create!()
1629+
1630+
Post
1631+
|> Ash.Changeset.for_create(:create, %{author_id: user1.id}, tenant: tenant1)
1632+
|> Ash.create!()
1633+
1634+
%{tenant1: tenant1, tenant2: tenant2, user1: user1, user2: user2}
1635+
end
1636+
1637+
test "bypass calculation returns 2 records, no-bypass returns 1 for same data", %{
1638+
tenant1: tenant1
1639+
} do
1640+
# No bypass: tenant1 only sees its own record
1641+
no_bypass =
1642+
User
1643+
|> Ash.Query.set_tenant(tenant1)
1644+
|> Ash.Query.load(:name_lower)
1645+
|> Ash.read!()
1646+
1647+
assert length(no_bypass) == 1
1648+
assert hd(no_bypass).name_lower == "alice"
1649+
1650+
# Bypass: same data, sees both tenants
1651+
bypass =
1652+
User
1653+
|> Ash.Query.for_read(:bypass_tenant)
1654+
|> Ash.Query.load(:name_lower_bypass)
1655+
|> Ash.read!()
1656+
1657+
assert length(bypass) == 2
1658+
assert Enum.map(bypass, & &1.name_lower_bypass) |> Enum.sort() == ["alice", "bob"]
1659+
end
1660+
1661+
test "bypass module calculation loads across tenants, no-bypass only loads within tenant", %{
1662+
tenant1: tenant1
1663+
} do
1664+
# No bypass: tenant1 sees 1 user with 1 post
1665+
no_bypass =
1666+
User
1667+
|> Ash.Query.set_tenant(tenant1)
1668+
|> Ash.Query.load(:cross_tenant_post_count)
1669+
|> Ash.read!()
1670+
1671+
assert length(no_bypass) == 1
1672+
assert hd(no_bypass).cross_tenant_post_count == 1
1673+
1674+
# Bypass: sees 2 users — alice with 1 post, bob with 0
1675+
bypass =
1676+
User
1677+
|> Ash.Query.for_read(:bypass_tenant)
1678+
|> Ash.Query.load(:cross_tenant_post_count)
1679+
|> Ash.read!()
1680+
1681+
assert length(bypass) == 2
1682+
1683+
counts = bypass |> Enum.sort_by(& &1.name) |> Enum.map(& &1.cross_tenant_post_count)
1684+
assert counts == [1, 0]
1685+
end
1686+
1687+
test "allow_global with tenant returns 1, without tenant returns 2", %{
1688+
tenant1: tenant1
1689+
} do
1690+
# With tenant: scoped to tenant1 only
1691+
with_tenant =
1692+
User
1693+
|> Ash.Query.for_read(:allow_global)
1694+
|> Ash.Query.set_tenant(tenant1)
1695+
|> Ash.Query.load(:name_lower_allow_global)
1696+
|> Ash.read!()
1697+
1698+
assert length(with_tenant) == 1
1699+
assert hd(with_tenant).name_lower_allow_global == "alice"
1700+
1701+
# Without tenant: sees all
1702+
without_tenant =
1703+
User
1704+
|> Ash.Query.for_read(:allow_global)
1705+
|> Ash.Query.load(:name_lower_allow_global)
1706+
|> Ash.read!()
1707+
1708+
assert length(without_tenant) == 2
1709+
end
1710+
1711+
test "bypass and no-bypass calculations on same query return different visibility", %{
1712+
tenant1: tenant1
1713+
} do
1714+
# Load both bypass and no-bypass on a bypass read (sees 2 records)
1715+
bypass_read =
1716+
User
1717+
|> Ash.Query.for_read(:bypass_tenant)
1718+
|> Ash.Query.load([:name_lower_bypass, :name_lower])
1719+
|> Ash.read!()
1720+
1721+
assert length(bypass_read) == 2
1722+
bypass_values = bypass_read |> Enum.map(& &1.name_lower_bypass) |> Enum.sort()
1723+
no_bypass_values = bypass_read |> Enum.map(& &1.name_lower) |> Enum.sort()
1724+
1725+
# Both calculations computed for all 2 records
1726+
assert bypass_values == ["alice", "bob"]
1727+
assert no_bypass_values == ["alice", "bob"]
1728+
1729+
# Tenanted read: only 1 record, so both calculations only see 1
1730+
tenanted_read =
1731+
User
1732+
|> Ash.Query.set_tenant(tenant1)
1733+
|> Ash.Query.load([:name_lower_bypass, :name_lower])
1734+
|> Ash.read!()
1735+
1736+
assert length(tenanted_read) == 1
1737+
assert hd(tenanted_read).name_lower_bypass == "alice"
1738+
assert hd(tenanted_read).name_lower == "alice"
1739+
end
1740+
1741+
test "context strategy allows bypass calculation (unlike aggregates)", %{
1742+
tenant1: tenant1
1743+
} do
1744+
Comment
1745+
|> Ash.Changeset.for_create(:create, %{name: "hello"}, tenant: tenant1)
1746+
|> Ash.create!()
1747+
1748+
# No bypass: requires tenant, returns 1
1749+
with_tenant =
1750+
Comment
1751+
|> Ash.Query.set_tenant(tenant1)
1752+
|> Ash.Query.load(:name_lower_bypass)
1753+
|> Ash.read!()
1754+
1755+
assert length(with_tenant) == 1
1756+
assert hd(with_tenant).name_lower_bypass == "hello"
1757+
1758+
bypass =
1759+
Comment
1760+
|> Ash.Query.for_read(:bypass_tenant)
1761+
|> Ash.Query.load(:name_lower_bypass)
1762+
|> Ash.read!()
1763+
1764+
assert is_list(bypass)
1765+
end
1766+
end
15761767
end

0 commit comments

Comments
 (0)