feat(api): add MapScoped bypass/read/write policy checks

This commit is contained in:
Guarzo
2026-08-01 17:15:04 +00:00
parent 4d146f9970
commit 771fa408db
2 changed files with 297 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
defmodule WandererApp.Api.Policies.MapScoped do
@moduledoc """
Policy checks scoping `/api/v1` requests to an `ActorWithMap` token's map.
Trusted internal actors (plain `User`/`Character`) are authorized via a
bypass and never reach the scoped checks below. A session
`ActorWithMap{map: nil}` is NOT trusted and must fall through to the scoped
(and therefore denying) checks.
"""
def trusted, do: {__MODULE__.Trusted, []}
def in_token_map(path) when is_list(path), do: {__MODULE__.InTokenMap, path: path}
def write_direct(attr \\ :map_id), do: {__MODULE__.WriteDirect, attr: attr}
def parent_in_token_map(path) when is_list(path), do: {__MODULE__.ParentInTokenMap, path: path}
def create_parent_in_token_map(parent_resource, fk),
do: {__MODULE__.CreateParentInTokenMap, parent_resource: parent_resource, fk: fk}
def create_map_matches_token, do: {__MODULE__.CreateMapMatchesToken, []}
defmodule Trusted do
@moduledoc """
Bypass check: matches only plain internal `User`/`Character` actors.
A session `ActorWithMap{map: nil}` must NEVER match this check.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_), do: "actor is a trusted internal User or Character"
@impl true
def match?(%WandererApp.Api.User{}, _ctx, _opts), do: true
def match?(%WandererApp.Api.Character{}, _ctx, _opts), do: true
def match?(_actor, _ctx, _opts), do: false
end
defmodule InTokenMap do
@moduledoc "Filters rows down to those belonging to the token's map."
use Ash.Policy.FilterCheck
alias WandererApp.Api.ActorHelpers
@impl true
def describe(_), do: "row belongs to the token's map"
@impl true
def filter(actor, _ctx, opts) do
path = Keyword.fetch!(opts, :path)
case ActorHelpers.get_map(%{actor: actor}) do
%{id: map_id} -> ref_eq(path, map_id)
_ -> expr(false)
end
end
defp ref_eq(path, v) do
{rel, [attr]} = Enum.split(path, -1)
expr(^Ash.Expr.ref(attr, rel) == ^v)
end
end
defmodule ParentInTokenMap do
@moduledoc "Filters rows down to those whose parent (via relationship path) belongs to the token's map."
use Ash.Policy.FilterCheck
alias WandererApp.Api.ActorHelpers
@impl true
def describe(_), do: "row's parent belongs to the token's map"
@impl true
def filter(actor, _ctx, opts) do
path = Keyword.fetch!(opts, :path)
case ActorHelpers.get_map(%{actor: actor}) do
%{id: map_id} ->
{rel, [attr]} = Enum.split(path, -1)
expr(^Ash.Expr.ref(attr, rel) == ^map_id)
_ ->
expr(false)
end
end
end
defmodule WriteDirect do
@moduledoc "Authorizes a write when the changeset's map_id attribute matches the token's map."
use Ash.Policy.SimpleCheck
alias WandererApp.Api.ActorHelpers
@impl true
def describe(_), do: "changeset target belongs to the token's map"
@impl true
def match?(actor, %{changeset: %Ash.Changeset{} = cs}, opts) do
attr = Keyword.get(opts, :attr, :map_id)
with %{id: map_id} <- ActorHelpers.get_map(%{actor: actor}),
row_map_id when not is_nil(row_map_id) <-
Ash.Changeset.get_attribute(cs, attr) do
row_map_id == map_id
else
_ -> false
end
end
def match?(_actor, _ctx, _opts), do: false
end
defmodule CreateParentInTokenMap do
@moduledoc """
Authorizes a create when the row's parent (looked up by foreign key)
belongs to the token's map.
"""
use Ash.Policy.SimpleCheck
require Ash.Query
alias WandererApp.Api.ActorHelpers
@impl true
def describe(_), do: "created row's parent belongs to the token's map"
@impl true
def match?(actor, %{changeset: %Ash.Changeset{} = cs}, opts) do
parent = Keyword.fetch!(opts, :parent_resource)
fk = Keyword.fetch!(opts, :fk)
with %{id: map_id} <- ActorHelpers.get_map(%{actor: actor}),
parent_id when not is_nil(parent_id) <- Ash.Changeset.get_attribute(cs, fk) do
case Ash.read_one(
Ash.Query.filter(parent, id == ^parent_id and map_id == ^map_id),
authorize?: false
) do
{:ok, nil} -> false
{:ok, _} -> true
_ -> false
end
else
_ -> false
end
end
def match?(_actor, _ctx, _opts), do: false
end
defmodule CreateMapMatchesToken do
@moduledoc """
Authorizes a create when the supplied `map_id` param/attribute is absent
(it will be injected from the token) or equals the token map. Forbids a
supplied foreign `map_id`.
"""
use Ash.Policy.SimpleCheck
alias WandererApp.Api.ActorHelpers
@impl true
def describe(_), do: "created row's supplied map_id matches the token map (or is absent)"
@impl true
def match?(actor, %{changeset: %Ash.Changeset{} = cs}, _opts) do
case ActorHelpers.get_map(%{actor: actor}) do
%{id: map_id} ->
supplied =
Map.get(cs.params || %{}, "map_id") || Map.get(cs.params || %{}, :map_id) ||
Ash.Changeset.get_attribute(cs, :map_id)
is_nil(supplied) or to_string(supplied) == to_string(map_id)
_ ->
false
end
end
def match?(_actor, _ctx, _opts), do: false
end
end
@@ -0,0 +1,125 @@
defmodule WandererApp.Api.Policies.MapScopedTest do
use WandererApp.DataCase, async: true
alias WandererApp.Api.Policies.MapScoped
alias WandererApp.Api.ActorWithMap
describe "Trusted" do
test "matches User" do
assert MapScoped.Trusted.match?(%WandererApp.Api.User{id: Ecto.UUID.generate()}, %{}, [])
end
test "matches Character" do
assert MapScoped.Trusted.match?(%WandererApp.Api.Character{id: Ecto.UUID.generate()}, %{}, [])
end
test "does NOT match session ActorWithMap{map: nil}" do
refute MapScoped.Trusted.match?(ActorWithMap.new(%{id: "u"}, nil), %{}, [])
end
test "does NOT match token ActorWithMap{map: %{}}" do
refute MapScoped.Trusted.match?(ActorWithMap.new(%{id: "u"}, %{id: "m"}), %{}, [])
end
test "does not match nil" do
refute MapScoped.Trusted.match?(nil, %{}, [])
end
end
describe "builders" do
test "in_token_map/1" do
assert {MapScoped.InTokenMap, path: [:map_id]} = MapScoped.in_token_map([:map_id])
end
test "create_parent_in_token_map/2" do
assert {MapScoped.CreateParentInTokenMap, parent_resource: WandererApp.Api.MapSystem, fk: :system_id} =
MapScoped.create_parent_in_token_map(WandererApp.Api.MapSystem, :system_id)
end
test "write_direct/1 default" do
assert {MapScoped.WriteDirect, attr: :map_id} = MapScoped.write_direct()
end
test "write_direct/1 custom attr" do
assert {MapScoped.WriteDirect, attr: :system_id} = MapScoped.write_direct(:system_id)
end
test "parent_in_token_map/1" do
assert {MapScoped.ParentInTokenMap, path: [:system, :map_id]} =
MapScoped.parent_in_token_map([:system, :map_id])
end
test "create_map_matches_token/0" do
assert {MapScoped.CreateMapMatchesToken, []} = MapScoped.create_map_matches_token()
end
end
describe "WriteDirect" do
setup do
map_id = Ecto.UUID.generate()
actor = ActorWithMap.new(%{id: "u"}, %{id: map_id})
%{map_id: map_id, actor: actor}
end
test "authorizes when changeset attribute matches token map", %{map_id: map_id, actor: actor} do
cs =
%WandererApp.Api.MapSystem{}
|> Ash.Changeset.new()
|> Ash.Changeset.force_change_attribute(:map_id, map_id)
assert MapScoped.WriteDirect.match?(actor, %{changeset: cs}, attr: :map_id)
end
test "denies when changeset attribute is a foreign map", %{actor: actor} do
cs =
%WandererApp.Api.MapSystem{}
|> Ash.Changeset.new()
|> Ash.Changeset.force_change_attribute(:map_id, Ecto.UUID.generate())
refute MapScoped.WriteDirect.match?(actor, %{changeset: cs}, attr: :map_id)
end
test "denies when actor has no token map", %{map_id: map_id} do
no_map_actor = ActorWithMap.new(%{id: "u"}, nil)
cs =
%WandererApp.Api.MapSystem{}
|> Ash.Changeset.new()
|> Ash.Changeset.force_change_attribute(:map_id, map_id)
refute MapScoped.WriteDirect.match?(no_map_actor, %{changeset: cs}, attr: :map_id)
end
end
describe "CreateMapMatchesToken" do
setup do
map_id = Ecto.UUID.generate()
actor = ActorWithMap.new(%{id: "u"}, %{id: map_id})
%{map_id: map_id, actor: actor}
end
test "authorizes when map_id absent (will be injected)", %{actor: actor} do
cs = Ash.Changeset.new(WandererApp.Api.MapSystem)
assert MapScoped.CreateMapMatchesToken.match?(actor, %{changeset: cs}, [])
end
test "authorizes when supplied map_id equals token map", %{map_id: map_id, actor: actor} do
cs = %{Ash.Changeset.new(WandererApp.Api.MapSystem) | params: %{"map_id" => map_id}}
assert MapScoped.CreateMapMatchesToken.match?(actor, %{changeset: cs}, [])
end
test "forbids when supplied map_id is a foreign map", %{actor: actor} do
cs = %{
Ash.Changeset.new(WandererApp.Api.MapSystem)
| params: %{"map_id" => Ecto.UUID.generate()}
}
refute MapScoped.CreateMapMatchesToken.match?(actor, %{changeset: cs}, [])
end
test "denies when actor has no token map" do
no_map_actor = ActorWithMap.new(%{id: "u"}, nil)
cs = Ash.Changeset.new(WandererApp.Api.MapSystem)
refute MapScoped.CreateMapMatchesToken.match?(no_map_actor, %{changeset: cs}, [])
end
end
end