From 771fa408dbd3e918608f28a202e0f2dd63ba71e2 Mon Sep 17 00:00:00 2001 From: Guarzo Date: Fri, 31 Jul 2026 19:52:52 +0000 Subject: [PATCH] feat(api): add MapScoped bypass/read/write policy checks --- lib/wanderer_app/api/policies/map_scoped.ex | 172 ++++++++++++++++++ .../api/policies/map_scoped_test.exs | 125 +++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 lib/wanderer_app/api/policies/map_scoped.ex create mode 100644 test/wanderer_app/api/policies/map_scoped_test.exs diff --git a/lib/wanderer_app/api/policies/map_scoped.ex b/lib/wanderer_app/api/policies/map_scoped.ex new file mode 100644 index 00000000..9ac060ca --- /dev/null +++ b/lib/wanderer_app/api/policies/map_scoped.ex @@ -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 diff --git a/test/wanderer_app/api/policies/map_scoped_test.exs b/test/wanderer_app/api/policies/map_scoped_test.exs new file mode 100644 index 00000000..5dd2a90e --- /dev/null +++ b/test/wanderer_app/api/policies/map_scoped_test.exs @@ -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