mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-09-26 06:56:23 +00:00
feat(api): add MapScoped bypass/read/write policy checks
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user