diff --git a/lib/acx/enforcer.ex b/lib/acx/enforcer.ex index 9385d69..97fbdae 100644 --- a/lib/acx/enforcer.ex +++ b/lib/acx/enforcer.ex @@ -353,6 +353,40 @@ defmodule Acx.Enforcer do end end + @doc """ + Loads policies from the persist adapter using the given filter. + Only matching policies are loaded into the enforcer. + + The filter is a map where keys can be `:ptype`, `:v0`, `:v1`, `:v2`, `:v3`, `:v4`, `:v5`, or `:v6`. + Values can be either a single string or a list of strings for matching multiple values. + + ## Examples + + # Load only policies for a specific domain + enforcer = Enforcer.load_filtered_policies!(enforcer, %{v3: "org:abc"}) + + # Load policies with multiple criteria + enforcer = Enforcer.load_filtered_policies!(enforcer, %{ptype: "p", v3: ["org:tenant_1", "org:tenant_2"]}) + """ + @spec load_filtered_policies!(t(), map()) :: t() + def load_filtered_policies!( + %__MODULE__{model: m, persist_adapter: adapter} = enforcer, + filter + ) + when is_map(filter) do + case PersistAdapter.load_filtered_policy(adapter, filter) do + {:ok, policies} -> + policies + |> Enum.map(fn [key | attrs] -> [String.to_atom(key) | attrs] end) + |> Enum.filter(fn [key | _] -> Model.has_policy_key?(m, key) end) + |> Enum.map(fn [key | attrs] -> {key, attrs} end) + |> Enum.reduce(enforcer, &load_policy!(&2, &1)) + + {:error, reason} -> + raise ArgumentError, message: reason + end + end + @doc """ Returns a list of policies in the given enforcer that match the given criteria. diff --git a/lib/acx/enforcer_server.ex b/lib/acx/enforcer_server.ex index adfada1..b568ab6 100644 --- a/lib/acx/enforcer_server.ex +++ b/lib/acx/enforcer_server.ex @@ -68,6 +68,16 @@ defmodule Acx.EnforcerServer do GenServer.call(via_tuple(ename), {:load_policies, pfile}) end + @doc """ + Loads filtered policies from the persist adapter. + Only policies matching the filter are loaded into the enforcer. + + See `Enforcer.load_filtered_policies!/2` for more details. + """ + def load_filtered_policies(ename, filter) do + GenServer.call(via_tuple(ename), {:load_filtered_policies, filter}) + end + @doc """ Returns a list of policies in the given enforcer that match the given criteria. @@ -245,6 +255,12 @@ defmodule Acx.EnforcerServer do {:reply, :ok, new_enforcer} end + def handle_call({:load_filtered_policies, filter}, _from, enforcer) do + new_enforcer = enforcer |> Enforcer.load_filtered_policies!(filter) + :ets.insert(:enforcers_table, {self_name(), new_enforcer}) + {:reply, :ok, new_enforcer} + end + def handle_call({:list_policies, criteria}, _from, enforcer) do policies = enforcer |> Enforcer.list_policies(criteria) {:reply, policies, enforcer} diff --git a/lib/acx/persist/ecto_adapter.ex b/lib/acx/persist/ecto_adapter.ex index a2e4870..adc1105 100644 --- a/lib/acx/persist/ecto_adapter.ex +++ b/lib/acx/persist/ecto_adapter.ex @@ -136,6 +136,82 @@ defmodule Acx.Persist.EctoAdapter do {:ok, policies} end + @doc """ + Loads only policies matching the given filter from the database. + + The filter is a map where keys can be `:ptype`, `:v0`, `:v1`, `:v2`, `:v3`, `:v4`, `:v5`, or `:v6`. + Values can be either a single string or a list of strings for matching multiple values. + + ## Examples + + # Load policies for a specific domain + filter = %{v3: "org:tenant_123"} + PersistAdapter.load_filtered_policy(adapter, filter) + + # Load policies with multiple criteria + filter = %{ptype: "p", v3: ["org:tenant_1", "org:tenant_2"]} + PersistAdapter.load_filtered_policy(adapter, filter) + + iex> PersistAdapter.load_filtered_policy(%Acx.Persist.EctoAdapter{repo: nil}, %{}) + ...> {:error, "repo is not set"} + """ + @spec load_filtered_policy(EctoAdapter.t(), map()) :: {:ok, [list()]} | {:error, String.t()} + def load_filtered_policy(%Acx.Persist.EctoAdapter{repo: nil}, _filter) do + {:error, "repo is not set"} + end + + def load_filtered_policy(adapter, filter) when is_map(filter) do + query = build_filtered_query(filter) + + policies = + adapter.repo.all(query) + |> Enum.map(&CasbinRule.changeset_to_list(&1)) + + {:ok, policies} + end + + defp build_filtered_query(filter) do + import Ecto.Query + base_query = from(r in CasbinRule) + + Enum.reduce(filter, base_query, fn {field, value}, query -> + add_where_clause(query, field, value) + end) + end + + # Helper function to add WHERE clause for a single filter condition + defp add_where_clause(query, field, values) when is_list(values) do + import Ecto.Query + + case field do + :ptype -> where(query, [r], r.ptype in ^values) + :v0 -> where(query, [r], r.v0 in ^values) + :v1 -> where(query, [r], r.v1 in ^values) + :v2 -> where(query, [r], r.v2 in ^values) + :v3 -> where(query, [r], r.v3 in ^values) + :v4 -> where(query, [r], r.v4 in ^values) + :v5 -> where(query, [r], r.v5 in ^values) + :v6 -> where(query, [r], r.v6 in ^values) + _ -> query + end + end + + defp add_where_clause(query, field, value) do + import Ecto.Query + + case field do + :ptype -> where(query, [r], r.ptype == ^value) + :v0 -> where(query, [r], r.v0 == ^value) + :v1 -> where(query, [r], r.v1 == ^value) + :v2 -> where(query, [r], r.v2 == ^value) + :v3 -> where(query, [r], r.v3 == ^value) + :v4 -> where(query, [r], r.v4 == ^value) + :v5 -> where(query, [r], r.v5 == ^value) + :v6 -> where(query, [r], r.v6 == ^value) + _ -> query + end + end + @doc """ Uses the configured repo to insert a Policy into the casbin_rule table. diff --git a/lib/acx/persist/persist_adapter.ex b/lib/acx/persist/persist_adapter.ex index f8e4861..7af08cc 100644 --- a/lib/acx/persist/persist_adapter.ex +++ b/lib/acx/persist/persist_adapter.ex @@ -1,5 +1,6 @@ defprotocol Acx.Persist.PersistAdapter do def load_policies(adapter) + def load_filtered_policy(adapter, filter) def add_policy(adapter, policy) def remove_policy(adapter, policy) def remove_filtered_policy(adapter, key, idx, attrs) diff --git a/lib/acx/persist/readonly_file_adapter.ex b/lib/acx/persist/readonly_file_adapter.ex index 5ab416a..4cacc6b 100644 --- a/lib/acx/persist/readonly_file_adapter.ex +++ b/lib/acx/persist/readonly_file_adapter.ex @@ -38,6 +38,76 @@ defmodule Acx.Persist.ReadonlyFileAdapter do {:ok, policies} end + @doc """ + Loads filtered policies from a file. + + The filter is applied in-memory after loading all policies. + Note: For file-based adapters, filtering does not improve performance + as all data must be read from disk anyway. + + ## Examples + + filter = %{ptype: "p", v3: "org:tenant_123"} + PersistAdapter.load_filtered_policy(adapter, filter) + """ + def load_filtered_policy(%Acx.Persist.ReadonlyFileAdapter{policy_file: nil}, _filter) do + {:ok, []} + end + + def load_filtered_policy(adapter, filter) when is_map(filter) do + case load_policies(adapter) do + {:ok, policies} -> + filtered_policies = apply_filter(policies, filter) + {:ok, filtered_policies} + + error -> + error + end + end + + defp apply_filter(policies, filter) do + Enum.filter(policies, fn policy -> + matches_filter?(policy, filter) + end) + end + + defp matches_filter?(policy, filter) do + Enum.all?(filter, fn {key, value} -> + policy_value = get_policy_value(policy, key) + matches_value?(policy_value, value) + end) + end + + defp get_policy_value([ptype | _values], :ptype), do: ptype + + defp get_policy_value([_ptype | values], key) do + index = + case key do + :v0 -> 0 + :v1 -> 1 + :v2 -> 2 + :v3 -> 3 + :v4 -> 4 + :v5 -> 5 + :v6 -> 6 + _ -> nil + end + + if index && index < length(values) do + Enum.at(values, index) + else + nil + end + end + + defp matches_value?(policy_value, filter_value) when is_list(filter_value) do + policy_value in filter_value + end + + defp matches_value?(policy_value, filter_value) do + policy_value == filter_value + end + def add_policy(adapter, _policy) do {:ok, adapter} end diff --git a/test/persist/filtered_policy_test.exs b/test/persist/filtered_policy_test.exs new file mode 100644 index 0000000..6184268 --- /dev/null +++ b/test/persist/filtered_policy_test.exs @@ -0,0 +1,26 @@ +defmodule Acx.Persist.FilteredPolicyTest do + use ExUnit.Case, async: true + alias Acx.Enforcer + alias Acx.Persist.EctoAdapter + alias Acx.Persist.PersistAdapter + + @cfile "../data/rbac_domain.conf" |> Path.expand(__DIR__) + + describe "load_filtered_policy/2 with EctoAdapter" do + test "returns error when repo is not set" do + adapter = EctoAdapter.new(nil) + assert {:error, "repo is not set"} == PersistAdapter.load_filtered_policy(adapter, %{}) + end + end + + describe "load_filtered_policies!/2 with Enforcer" do + test "raises error when repo is not set" do + adapter = EctoAdapter.new(nil) + {:ok, e} = Enforcer.init(@cfile, adapter) + + assert_raise ArgumentError, "repo is not set", fn -> + Enforcer.load_filtered_policies!(e, %{v2: "domain1"}) + end + end + end +end diff --git a/test/persist/readonly_file_adapter_filtered_test.exs b/test/persist/readonly_file_adapter_filtered_test.exs new file mode 100644 index 0000000..921f00c --- /dev/null +++ b/test/persist/readonly_file_adapter_filtered_test.exs @@ -0,0 +1,113 @@ +defmodule Acx.Persist.ReadonlyFileAdapterFilteredTest do + use ExUnit.Case, async: true + alias Acx.Enforcer + alias Acx.Persist.ReadonlyFileAdapter + alias Acx.Persist.PersistAdapter + + @cfile "../data/rbac_domain.conf" |> Path.expand(__DIR__) + @pfile "../data/rbac_domain.csv" |> Path.expand(__DIR__) + + describe "load_filtered_policy/2 with ReadonlyFileAdapter" do + test "filters policies by domain" do + adapter = ReadonlyFileAdapter.new(@pfile) + + # Load only policies for domain1 (domain is at v1 position) + {:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{v1: "domain1"}) + + # Should only have policies with domain1 + assert length(policies) == 2 + assert Enum.all?(policies, fn [_ptype, _subj, domain, _obj, _act] -> domain == "domain1" end) + end + + test "filters policies by ptype" do + adapter = ReadonlyFileAdapter.new(@pfile) + + # Load only p (policy) rules, not g (role) rules + {:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{ptype: "p"}) + + # Should only have p rules + assert length(policies) == 5 + assert Enum.all?(policies, fn [ptype | _] -> ptype == "p" end) + end + + test "filters policies by multiple criteria" do + adapter = ReadonlyFileAdapter.new(@pfile) + + # Load only p rules for domain2 (domain is at v1 position) + {:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{ptype: "p", v1: "domain2"}) + + # Should only have p rules with domain2 + assert length(policies) == 2 + assert Enum.all?(policies, fn [ptype, _subj, domain, _obj, _act] -> + ptype == "p" && domain == "domain2" + end) + end + + test "filters policies by list of values" do + adapter = ReadonlyFileAdapter.new(@pfile) + + # Load policies for domain1 OR domain2 (domain is at v1 position) + {:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{v1: ["domain1", "domain2"]}) + + # Should have policies with domain1 or domain2 + assert length(policies) == 4 + assert Enum.all?(policies, fn [_ptype, _subj, domain, _obj, _act] -> + domain in ["domain1", "domain2"] + end) + end + + test "returns empty list when policy file is nil" do + adapter = ReadonlyFileAdapter.new() + assert {:ok, []} == PersistAdapter.load_filtered_policy(adapter, %{}) + end + + test "returns empty list when no policies match filter" do + adapter = ReadonlyFileAdapter.new(@pfile) + + # Load policies for non-existent domain + {:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{v1: "non_existent_domain"}) + + assert policies == [] + end + end + + describe "load_filtered_policies!/2 with Enforcer and ReadonlyFileAdapter" do + test "loads only filtered policies into enforcer" do + {:ok, e} = Enforcer.init(@cfile) + adapter = ReadonlyFileAdapter.new(@pfile) + e = Enforcer.set_persist_adapter(e, adapter) + + # Load only policies for domain1 (domain is at v1 position) + e = Enforcer.load_filtered_policies!(e, %{v1: "domain1"}) + |> Enforcer.load_mapping_policies!() + + # Should only have domain1 policies + policies = Enforcer.list_policies(e) + assert length(policies) == 2 + + # Domain1 requests should work + assert Enforcer.allow?(e, ["alice", "domain1", "data1", "read"]) === true + assert Enforcer.allow?(e, ["alice", "domain1", "data1", "write"]) === true + + # Domain2 requests should not work (policies not loaded) + assert Enforcer.allow?(e, ["alice", "domain2", "data2", "read"]) === false + assert Enforcer.allow?(e, ["bob", "domain2", "data2", "read"]) === false + end + + test "loads only p policies when filtered by ptype" do + {:ok, e} = Enforcer.init(@cfile) + adapter = ReadonlyFileAdapter.new(@pfile) + e = Enforcer.set_persist_adapter(e, adapter) + + # Load only p rules, not g rules + e = Enforcer.load_filtered_policies!(e, %{ptype: "p"}) + + policies = Enforcer.list_policies(e) + assert length(policies) == 5 + + # Without role mappings loaded, role-based permissions should not work + mapping_policies = Enforcer.list_mapping_policies(e) + assert length(mapping_policies) == 0 + end + end +end diff --git a/test/support/mock_repo.ex b/test/support/mock_repo.ex index 9def80b..5deaa7b 100644 --- a/test/support/mock_repo.ex +++ b/test/support/mock_repo.ex @@ -16,7 +16,10 @@ defmodule Acx.Persist.MockRepo do |> then(&Map.merge(%Acx.Persist.EctoAdapter.CasbinRule{id: id}, &1)) end - def all(CasbinRule, _opts \\ []) do + # Define function head with default parameter + def all(query_or_schema, opts \\ []) + + def all(CasbinRule, _opts) do unquote(pfile) |> File.read!() |> String.split("\n", trim: true) @@ -25,6 +28,13 @@ defmodule Acx.Persist.MockRepo do |> Enum.map(fn {rule, id} -> to_changeset(id, rule) end) end + # Support for Ecto.Query - delegates to CasbinRule for simplicity + # In a real database, Ecto would apply the query filters + # For testing filtered policies, use ReadonlyFileAdapter tests instead + def all(%Ecto.Query{}, _opts) do + all(CasbinRule) + end + def insert(changeset, opts \\ []) def insert(%Changeset{errors: [], changes: values}, _opts) do @@ -38,6 +48,9 @@ defmodule Acx.Persist.MockRepo do def delete_all(queryset) do {1, nil} end + + # Allow override in using modules + defoverridable all: 1, all: 2 end end end