Skip to content

Commit c19fd85

Browse files
committed
Improve hawk.gen.resource
1 parent 2a49572 commit c19fd85

13 files changed

Lines changed: 587 additions & 47 deletions

README.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,13 @@ mix hawk.gen.resource MyApp.Courses MyApp.Course \
7272

7373
This creates the facade, policy, reader, JSON:API adapter, LiveView adapter, and
7474
writer skeleton. Pass `--read-only` to generate `writer: false` and omit the
75-
writer. The generator is intentionally conservative: it gives you the standard
76-
Hawk shape, then you tighten policy, filters, labels, docs, and writer rules by
77-
hand.
75+
writer. Pass `--web MyAppWeb` to also generate a Phoenix JSON:API controller,
76+
clickable LiveView index/show modules and templates, and a router snippet file
77+
beside the generated web files. The generated LiveViews use
78+
`Hawk.Authority.Session.authority_or_public/1`, so they work for public demos and
79+
can later pick up a session-backed authority. The generator is intentionally
80+
conservative: it gives you the standard Hawk shape, then you tighten policy,
81+
filters, labels, docs, and writer rules by hand.
7882

7983
### JSON:API adapter
8084

@@ -301,7 +305,12 @@ end
301305
`public` is anonymous readonly access. It is not system access and still goes
302306
through the resource policy. Policies expose their read declarations for
303307
contract validation, so `ResourceContract` can catch scoped policy filters that
304-
are not declared by the reader.
308+
are not declared by the reader. For simple ownership-based writes, pass
309+
`owned_by:` to require model/changeset fields to match authority scopes:
310+
311+
```elixir
312+
write(roles: [:teacher], owned_by: [teacher_id: :teacher_id])
313+
```
305314

306315
Policy matrix tests can use `Hawk.Policy.Assertions` to keep role coverage
307316
compact:
@@ -381,6 +390,25 @@ defmodule MyApp.Courses.Writer do
381390
end
382391
```
383392

393+
### Authority conventions
394+
395+
Hawk does not authenticate users itself. Apps can use the small session/assign
396+
convention helpers to carry an already-resolved authority through controllers and
397+
LiveViews:
398+
399+
```elixir
400+
authority = MyAppWeb.Auth.authority_for(conn)
401+
conn = Hawk.Authority.Plug.call(conn, resolver: fn _conn -> authority end)
402+
403+
session_authority = Hawk.Authority.Session.dump(authority)
404+
authority = Hawk.Authority.Session.authority_or_public(session)
405+
```
406+
407+
`Hawk.Authority.Plug` assigns `:hawk_authority` on the conn, while
408+
`Hawk.LiveView.AuthorityHook` can assign the same key from a dumped session value
409+
with LiveView `on_mount`. Missing authority falls back to readonly public access,
410+
not system access.
411+
384412
`Hawk.Writer.Resource` generates `change_create/2` / `create/2` and
385413
`change_update/3` / `update/3` from the same pipelines. `change_*` functions
386414
return non-persisting changesets with `action: :validate`, which is the boundary
@@ -721,8 +749,10 @@ end
721749
```
722750

723751
The contract test checks that JSON:API attributes, relationships,
724-
creatable/updatable fields, reader preloads, sorts, and filters agree with the
725-
model and reader declarations.
752+
creatable/updatable fields, reader preloads, sorts, filters, and scoped policy
753+
filters agree with the model, reader, and policy declarations. For resources
754+
where every exposed relationship is expected to be include/preloadable, call
755+
`Hawk.ResourceContract.validate!/3` with `require_relationship_preloads: true`.
726756

727757
### JSON:API controller contract test
728758

docs/hawk-resource-direction.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ policy_filter AND caller_filter AND resource_forced_filter
7777

7878
Policy is the security boundary. Adapter filters from JSON:API, LiveView params, or internal callers are additional narrowing only. Unknown filters fail closed. Preloads use the related resource's own Reader and Policy.
7979

80-
Policy declarations are introspectable so resource contracts can validate the seam between Policy and Reader: every scoped policy filter key must be declared as a reader filter or custom filter handler. Role matrices should be tested with Hawk's policy assertion helpers rather than repeated hand-written `read_filter/1` assertions.
80+
Policy declarations are introspectable so resource contracts can validate the seam between Policy and Reader: every scoped policy filter key must be declared as a reader filter or custom filter handler. Role matrices should be tested with Hawk's policy assertion helpers rather than repeated hand-written `read_filter/1` assertions. Apps can use the session/assign authority convention helpers for Plug and LiveView handoff, but authentication remains application-owned.
8181

8282
There are no hidden read bypasses. Even system reads should use `Authority.system()` and flow through the same machinery.
8383

@@ -87,7 +87,7 @@ There are no hidden read bypasses. Even system reads should use `Authority.syste
8787

8888
System writes should also validate policy explicitly; `system` can be allowed, but it should not bypass the policy-validation path.
8989

90-
JSON:API relationship reads can expose projections over internal database shape, including `many_to_many` associations where the join schema is not itself part of the external API. JSON:API relationship writes are intentionally limited to `belongs_to` associations because those map cleanly to writer attrs through the owning foreign key. `has_many`, `has_one`, and `many_to_many` mutations need explicit writer/action workflows instead of being implied from JSON:API relationship linkage. Plain CRUD deletion can use the writer DSL's `delete(:default)` helper; ownership-specific or cascading deletes should remain explicit writer/action workflows.
90+
JSON:API relationship reads can expose projections over internal database shape, including `many_to_many` associations where the join schema is not itself part of the external API. JSON:API relationship writes are intentionally limited to `belongs_to` associations because those map cleanly to writer attrs through the owning foreign key. `has_many`, `has_one`, and `many_to_many` mutations need explicit writer/action workflows instead of being implied from JSON:API relationship linkage. Plain CRUD deletion can use the writer DSL's `delete(:default)` helper. Simple owner-scoped writers can use `write(..., owned_by: [field: :scope])`; cascading deletes and multi-resource ownership rules should remain explicit writer/action workflows.
9191

9292
Actions are resource-scoped workflows/commands. They may orchestrate across resources, use `Ecto.Multi`, send emails, enqueue jobs, and perform broader side effects. They remain declared, authorized, documented, telemetry-instrumented, and testable.
9393

@@ -157,6 +157,6 @@ Telemetry should avoid raw IDs, request params, mutation attrs, and sensitive pa
157157

158158
## Route and capability consistency
159159

160-
Hawk should eventually provide explicit Phoenix route macros that generate only supported routes. Hand-written routes remain possible, but contract tests should catch route/capability drift.
160+
Hawk's resource generator can produce the first Phoenix layer — JSON:API controller, clickable LiveView index/show files, and a router snippet — while apps still decide where routes belong. Full route macros can come later if repeated apps show the snippets are still too manual. Hand-written routes remain possible, but contract tests should catch route/capability drift.
161161

162162
`use Hawk.JsonApi.Controller` and `use Hawk.LiveView` should eventually need only `resource: MyApp.Courses`, with model and adapter metadata inferred from the resource facade.

lib/hawk/authority/plug.ex

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
defmodule Hawk.Authority.Plug do
2+
@moduledoc """
3+
Optional Plug-style authority assignment convention.
4+
5+
Configure with a resolver that turns a conn into `Hawk.Authority`:
6+
7+
plug Hawk.Authority.Plug, resolver: &MyAppWeb.Auth.authority/1
8+
9+
If the resolver returns `nil`, Hawk assigns `Hawk.Authority.public()`.
10+
The authority is stored in `conn.assigns[:hawk_authority]` by default.
11+
"""
12+
13+
alias Hawk.Authority
14+
alias Hawk.Authority.Session
15+
16+
def init(opts), do: opts
17+
18+
def call(conn, opts) do
19+
key = Keyword.get(opts, :assign, Session.default_key())
20+
resolver = Keyword.get(opts, :resolver, fn _conn -> nil end)
21+
22+
authority =
23+
case resolver.(conn) do
24+
%Authority{} = authority -> authority
25+
nil -> Authority.public()
26+
end
27+
28+
Session.assign_authority(conn, authority, key)
29+
end
30+
end

lib/hawk/authority/session.ex

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
defmodule Hawk.Authority.Session do
2+
@moduledoc """
3+
Small helpers for carrying `Hawk.Authority` through web sessions and assigns.
4+
5+
Hawk does not own application authentication. Apps resolve their current user
6+
however they like, then store the resulting authority with these stable keys so
7+
controllers and LiveViews can share the same convention.
8+
"""
9+
10+
alias Hawk.Authority
11+
12+
@default_key :hawk_authority
13+
14+
@doc """
15+
Returns Hawk's default assign/session key for authorities.
16+
"""
17+
def default_key, do: @default_key
18+
19+
@doc """
20+
Converts an authority into a session-safe map.
21+
"""
22+
def dump(%Authority{} = authority) do
23+
%{
24+
"role" => authority.role,
25+
"identity" => authority.identity,
26+
"readonly?" => authority.readonly?,
27+
"system?" => authority.system?,
28+
"public?" => authority.public?,
29+
"scopes" => authority.scopes,
30+
"meta" => authority.meta
31+
}
32+
end
33+
34+
@doc """
35+
Rebuilds an authority from `dump/1` output.
36+
"""
37+
def load(%{"role" => role, "identity" => identity} = data) do
38+
%Authority{
39+
role: normalize_atom(role),
40+
identity: identity,
41+
readonly?: Map.get(data, "readonly?", false),
42+
system?: Map.get(data, "system?", false),
43+
public?: Map.get(data, "public?", false),
44+
scopes: normalize_atom_keys(Map.get(data, "scopes", %{})),
45+
meta: Map.get(data, "meta", %{})
46+
}
47+
end
48+
49+
def load(%{role: role, identity: identity} = data) do
50+
%Authority{
51+
role: normalize_atom(role),
52+
identity: identity,
53+
readonly?: Map.get(data, :readonly?, false),
54+
system?: Map.get(data, :system?, false),
55+
public?: Map.get(data, :public?, false),
56+
scopes: normalize_atom_keys(Map.get(data, :scopes, %{})),
57+
meta: Map.get(data, :meta, %{})
58+
}
59+
end
60+
61+
def load(nil), do: nil
62+
63+
@doc """
64+
Stores an authority under the configured assign key on a socket/conn/map.
65+
"""
66+
def assign_authority(socket_or_conn, %Authority{} = authority, key \\ @default_key) do
67+
assign_value(socket_or_conn, key, authority)
68+
end
69+
70+
@doc """
71+
Fetches an authority from assigns or session-like maps.
72+
"""
73+
def fetch_authority(source, key \\ @default_key) do
74+
source
75+
|> fetch_value(key)
76+
|> case do
77+
%Authority{} = authority -> {:ok, authority}
78+
nil -> :error
79+
dumped -> {:ok, load(dumped)}
80+
end
81+
end
82+
83+
@doc """
84+
Fetches an authority or returns `Hawk.Authority.public()`.
85+
"""
86+
def authority_or_public(source, key \\ @default_key) do
87+
case fetch_authority(source, key) do
88+
{:ok, authority} -> authority
89+
:error -> Authority.public()
90+
end
91+
end
92+
93+
defp assign_value(%{assigns: assigns} = value, key, authority) when is_map(assigns) do
94+
put_in(value.assigns, Map.put(assigns, key, authority))
95+
end
96+
97+
defp assign_value(value, key, authority) when is_map(value) do
98+
Map.put(value, key, authority)
99+
end
100+
101+
defp fetch_value(%{assigns: assigns}, key) when is_map(assigns), do: Map.get(assigns, key)
102+
defp fetch_value(%{} = map, key), do: Map.get(map, key) || Map.get(map, to_string(key))
103+
104+
defp normalize_atom(value) when is_atom(value), do: value
105+
defp normalize_atom(value) when is_binary(value), do: String.to_existing_atom(value)
106+
107+
defp normalize_atom_keys(map) when is_map(map) do
108+
Map.new(map, fn {key, value} -> {normalize_atom(key), value} end)
109+
end
110+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
defmodule Hawk.LiveView.AuthorityHook do
2+
@moduledoc """
3+
Optional LiveView `on_mount` convention for assigning Hawk authorities.
4+
5+
Use after storing a dumped authority in the session with
6+
`Hawk.Authority.Session.dump/1`:
7+
8+
on_mount {Hawk.LiveView.AuthorityHook, []}
9+
10+
The hook assigns `:hawk_authority`, falling back to `Hawk.Authority.public()`.
11+
"""
12+
13+
alias Hawk.Authority.Session
14+
15+
def on_mount(opts, _params, session, socket) do
16+
key = Keyword.get(opts, :assign, Session.default_key())
17+
session_key = Keyword.get(opts, :session_key, key)
18+
authority = Session.authority_or_public(session, session_key)
19+
20+
{:cont, Session.assign_authority(socket, authority, key)}
21+
end
22+
end

lib/hawk/policy.ex

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,23 +53,50 @@ defmodule Hawk.Policy do
5353

5454
defmacro write(opts) when is_list(opts) do
5555
roles = Keyword.fetch!(opts, :roles)
56+
owned_by = Keyword.get(opts, :owned_by, [])
5657

5758
quote do
58-
def create?(%Hawk.MutationContext{} = context), do: write_allowed?(context.authority)
59-
def update?(%Hawk.MutationContext{} = context), do: write_allowed?(context.authority)
60-
def delete?(%Hawk.MutationContext{} = context), do: write_allowed?(context.authority)
59+
def create?(%Hawk.MutationContext{} = context),
60+
do: write_allowed?(context, unquote(owned_by))
61+
62+
def update?(%Hawk.MutationContext{} = context),
63+
do: write_allowed?(context, unquote(owned_by))
64+
65+
def delete?(%Hawk.MutationContext{} = context),
66+
do: write_allowed?(context, unquote(owned_by))
67+
68+
defp write_allowed?(%Hawk.MutationContext{} = context, ownership) do
69+
authority = context.authority
6170

62-
defp write_allowed?(%Hawk.Authority{} = authority) do
6371
cond do
6472
Hawk.Authority.system?(authority) -> true
6573
Hawk.Authority.readonly?(authority) -> false
66-
authority.role in unquote(roles) -> true
74+
authority.role in unquote(roles) -> Hawk.Policy.owned_by?(context, ownership)
6775
true -> false
6876
end
6977
end
7078
end
7179
end
7280

81+
def owned_by?(_context, []), do: true
82+
83+
def owned_by?(%Hawk.MutationContext{} = context, ownership) when is_list(ownership) do
84+
Enum.all?(ownership, fn {field, scope} ->
85+
with {:ok, scope_value} <- Hawk.Authority.fetch_scope(context.authority, scope),
86+
{:ok, field_value} <- mutation_field(context, field) do
87+
field_value == scope_value
88+
else
89+
_missing -> false
90+
end
91+
end)
92+
end
93+
94+
defp mutation_field(%Hawk.MutationContext{} = context, field) when is_atom(field) do
95+
value = Ecto.Changeset.get_field(context.changeset, field)
96+
97+
if is_nil(value), do: Map.fetch(context.model, field), else: {:ok, value}
98+
end
99+
73100
defp literal_option!(value, _caller) when is_map(value), do: value
74101

75102
defp literal_option!(quoted, caller) do

lib/hawk/resource_contract.ex

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ defmodule Hawk.ResourceContract do
33
Validates that a Hawk resource's declarations agree with its Ecto model.
44
"""
55

6-
def validate!(resource, model) when is_atom(resource) and is_atom(model) do
6+
def validate!(resource, model, opts \\ []) when is_atom(resource) and is_atom(model) do
77
json_api = validate_model!(model, json_api_metadata(resource, model))
88
reader = resource_module(resource, :reader, Reader)
99
policy = resource_module(resource, :policy, Policy)
1010

1111
validate_reader_preloads!(reader, json_api)
12+
maybe_validate_relationship_preloads!(reader, json_api, opts)
1213
validate_reader_sorts!(reader, model)
1314
validate_reader_filters!(reader, model)
1415
validate_policy_filters!(policy, reader)
@@ -115,6 +116,17 @@ defmodule Hawk.ResourceContract do
115116
|> raise_if_any!("reader preloads must be declared JSON:API relationships")
116117
end
117118

119+
defp maybe_validate_relationship_preloads!(reader, json_api, opts) do
120+
if Keyword.get(opts, :require_relationship_preloads, false) do
121+
preloads = reader |> reader_values(:preload_keys) |> MapSet.new()
122+
123+
json_api.relationships
124+
|> Map.keys()
125+
|> Enum.reject(&MapSet.member?(preloads, &1))
126+
|> raise_if_any!("JSON:API relationships must be declared reader preloads")
127+
end
128+
end
129+
118130
defp validate_reader_sorts!(reader, model) do
119131
schema_fields = model.__schema__(:fields) |> MapSet.new()
120132

0 commit comments

Comments
 (0)