Skip to content

Commit 66068b5

Browse files
improvement: Split up usage rules into sub-rules (#2561)
This aims to follow progressive disclosure patterns by keeping the core usage rules short and splitting each of the existin file's headings into its own file.
1 parent f97e621 commit 66068b5

15 files changed

+1414
-1331
lines changed

usage-rules.md

Lines changed: 0 additions & 1331 deletions
Large diffs are not rendered by default.

usage-rules/actions.md

Lines changed: 409 additions & 0 deletions
Large diffs are not rendered by default.

usage-rules/aggregates.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2019 ash contributors <https://github.com/ash-project/ash/graphs/contributors>
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Aggregates
8+
9+
Aggregates allow you to retrieve summary information over groups of related data, like counts, sums, or averages. Define aggregates in the `aggregates` block of a resource.
10+
11+
Aggregates can work over relationships or directly over unrelated resources:
12+
13+
```elixir
14+
aggregates do
15+
# Related aggregates - use relationship path
16+
count :published_post_count, :posts do
17+
filter expr(published == true)
18+
end
19+
20+
sum :total_sales, :orders, :amount
21+
22+
exists :is_admin, :roles do
23+
filter expr(name == "admin")
24+
end
25+
26+
# Unrelated aggregates - use resource module directly
27+
count :matching_profiles_count, Profile do
28+
filter expr(name == parent(name))
29+
end
30+
31+
sum :total_report_score, Report, :score do
32+
filter expr(author_name == parent(name))
33+
end
34+
35+
exists :has_reports, Report do
36+
filter expr(author_name == parent(name))
37+
end
38+
end
39+
```
40+
41+
For unrelated aggregates, use `parent/1` to reference fields from the source resource.
42+
43+
## Aggregate Types
44+
45+
- **count**: Counts related items meeting criteria
46+
- **sum**: Sums a field across related items
47+
- **exists**: Returns boolean indicating if matching related items exist (also supports unrelated resources)
48+
- **first**: Gets the first related value matching criteria
49+
- **list**: Lists the related values for a specific field
50+
- **max**: Gets the maximum value of a field
51+
- **min**: Gets the minimum value of a field
52+
- **avg**: Gets the average value of a field
53+
54+
## Using Aggregates
55+
56+
```elixir
57+
# Using code interface options (preferred)
58+
users = MyDomain.list_users!(
59+
load: [:published_post_count, :total_sales],
60+
query: [
61+
filter: [published_post_count: [greater_than: 5]],
62+
sort: [published_post_count: :desc]
63+
]
64+
)
65+
66+
# Manual query building (for complex cases)
67+
User |> Ash.Query.filter(published_post_count > 5) |> Ash.read!()
68+
69+
# Loading on existing records
70+
Ash.load!(users, :published_post_count)
71+
```
72+
73+
### Join Filters
74+
75+
For complex aggregates involving multiple relationships, use join filters:
76+
77+
```elixir
78+
aggregates do
79+
sum :redeemed_deal_amount, [:redeems, :deal], :amount do
80+
# Filter on the aggregate as a whole
81+
filter expr(redeems.redeemed == true)
82+
83+
# Apply filters to specific relationship steps
84+
join_filter :redeems, expr(redeemed == true)
85+
join_filter [:redeems, :deal], expr(active == parent(require_active))
86+
end
87+
end
88+
```
89+
90+
## Inline Aggregates
91+
92+
Use aggregates inline within expressions:
93+
94+
```elixir
95+
# Related inline aggregates
96+
calculate :grade_percentage, :decimal, expr(
97+
count(answers, query: [filter: expr(correct == true)]) * 100 /
98+
count(answers)
99+
)
100+
101+
# Unrelated inline aggregates
102+
calculate :profile_count, :integer, expr(
103+
count(Profile, filter: expr(name == parent(name)))
104+
)
105+
106+
calculate :stats, :map, expr(%{
107+
profiles: count(Profile, filter: expr(active == true)),
108+
reports: count(Report, filter: expr(author_name == parent(name))),
109+
has_active_profile: exists(Profile, active == true and name == parent(name))
110+
})
111+
```
112+

usage-rules/authorization.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2019 ash contributors <https://github.com/ash-project/ash/graphs/contributors>
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Authorization
8+
9+
- When performing administrative actions, you can bypass authorization with `authorize?: false`
10+
- To run actions as a particular user, look that user up and pass it as the `actor` option
11+
- Always set the actor on the query/changeset/input, not when calling the action
12+
- Use policies to define authorization rules
13+
14+
```elixir
15+
# Good
16+
Post
17+
|> Ash.Query.for_read(:read, %{}, actor: current_user)
18+
|> Ash.read!()
19+
20+
# BAD, DO NOT DO THIS
21+
Post
22+
|> Ash.Query.for_read(:read, %{})
23+
|> Ash.read!(actor: current_user)
24+
```
25+
26+
## Policies
27+
28+
To use policies, add the `Ash.Policy.Authorizer` to your resource:
29+
30+
```elixir
31+
defmodule MyApp.Post do
32+
use Ash.Resource,
33+
domain: MyApp.Blog,
34+
authorizers: [Ash.Policy.Authorizer]
35+
36+
# Rest of resource definition...
37+
end
38+
```
39+
40+
## Policy Basics
41+
42+
Policies determine what actions on a resource are permitted for a given actor. Define policies in the `policies` block:
43+
44+
```elixir
45+
policies do
46+
# A simple policy that applies to all read actions
47+
policy action_type(:read) do
48+
# Authorize if record is public
49+
authorize_if expr(public == true)
50+
51+
# Authorize if actor is the owner
52+
authorize_if relates_to_actor_via(:owner)
53+
end
54+
55+
# A policy for create actions
56+
policy action_type(:create) do
57+
# Only allow active users to create records
58+
forbid_unless actor_attribute_equals(:active, true)
59+
60+
# Ensure the record being created relates to the actor
61+
authorize_if relating_to_actor(:owner)
62+
end
63+
end
64+
```
65+
66+
## Policy Evaluation Flow
67+
68+
Policies evaluate from top to bottom with the following logic:
69+
70+
1. All policies that apply to an action must pass for the action to be allowed
71+
2. Within each policy, checks evaluate from top to bottom
72+
3. The first check that produces a decision determines the policy result
73+
4. If no check produces a decision, the policy defaults to forbidden
74+
75+
## IMPORTANT: Policy Check Logic
76+
77+
**the first check that yields a result determines the policy outcome**
78+
79+
```elixir
80+
# WRONG - This is OR logic, not AND logic!
81+
policy action_type(:update) do
82+
authorize_if actor_attribute_equals(:admin?, true) # If this passes, policy passes
83+
authorize_if relates_to_actor_via(:owner) # Only checked if first fails
84+
end
85+
```
86+
87+
To require BOTH conditions in that example, you would use `forbid_unless` for the first condition:
88+
89+
```elixir
90+
# CORRECT - This requires BOTH conditions
91+
policy action_type(:update) do
92+
forbid_unless actor_attribute_equals(:admin?, true) # Must be admin
93+
authorize_if relates_to_actor_via(:owner) # AND must be owner
94+
end
95+
```
96+
97+
Alternative patterns for AND logic:
98+
- Use multiple separate policies (each must pass independently)
99+
- Use a single complex expression with `expr(condition1 and condition2)`
100+
- Use `forbid_unless` for required conditions, then `authorize_if` for the final check
101+
102+
## Bypass Policies
103+
104+
Use bypass policies to allow certain actors to bypass other policy restrictions. This should be used almost exclusively for admin bypasses.
105+
106+
```elixir
107+
policies do
108+
# Bypass policy for admins - if this passes, other policies don't need to pass
109+
bypass actor_attribute_equals(:admin, true) do
110+
authorize_if always()
111+
end
112+
113+
# Regular policies follow...
114+
policy action_type(:read) do
115+
# ...
116+
end
117+
end
118+
```
119+
120+
## Field Policies
121+
122+
Field policies control access to specific fields (attributes, calculations, aggregates):
123+
124+
```elixir
125+
field_policies do
126+
# Only supervisors can see the salary field
127+
field_policy :salary do
128+
authorize_if actor_attribute_equals(:role, :supervisor)
129+
end
130+
131+
# Allow access to all other fields
132+
field_policy :* do
133+
authorize_if always()
134+
end
135+
end
136+
```
137+
138+
## Policy Checks
139+
140+
There are two main types of checks used in policies:
141+
142+
1. **Simple checks** - Return true/false answers (e.g., "is the actor an admin?")
143+
2. **Filter checks** - Return filters to apply to data (e.g., "only show records owned by the actor")
144+
145+
You can use built-in checks or create custom ones:
146+
147+
```elixir
148+
# Built-in checks
149+
authorize_if actor_attribute_equals(:role, :admin)
150+
authorize_if relates_to_actor_via(:owner)
151+
authorize_if expr(public == true)
152+
153+
# Custom check module
154+
authorize_if MyApp.Checks.ActorHasPermission
155+
```
156+
157+
### Custom Policy Checks
158+
159+
Create custom checks by implementing `Ash.Policy.SimpleCheck` or `Ash.Policy.FilterCheck`:
160+
161+
```elixir
162+
# Simple check - returns true/false
163+
defmodule MyApp.Checks.ActorHasRole do
164+
use Ash.Policy.SimpleCheck
165+
166+
def match?(%{role: actor_role}, _context, opts) do
167+
actor_role == (opts[:role] || :admin)
168+
end
169+
def match?(_, _, _), do: false
170+
end
171+
172+
# Filter check - returns query filter
173+
defmodule MyApp.Checks.VisibleToUserLevel do
174+
use Ash.Policy.FilterCheck
175+
176+
def filter(actor, _authorizer, _opts) do
177+
expr(visibility_level <= ^actor.user_level)
178+
end
179+
end
180+
181+
# Usage
182+
policy action_type(:read) do
183+
authorize_if {MyApp.Checks.ActorHasRole, role: :manager}
184+
authorize_if MyApp.Checks.VisibleToUserLevel
185+
end
186+
```
187+

0 commit comments

Comments
 (0)