From d7449eb0db93324f45c594374bbb3fa1615fa5d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:43:25 +0000 Subject: [PATCH 1/5] Initial plan From cf4dbd99accc16131c9d20ad367adce87f468a75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:57:33 +0000 Subject: [PATCH 2/5] Add load_policies_from_adapter functionality to EnforcerServer Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- lib/acx/enforcer_server.ex | 22 +++ .../load_policies_from_adapter_test.exs | 133 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 test/persist/load_policies_from_adapter_test.exs diff --git a/lib/acx/enforcer_server.ex b/lib/acx/enforcer_server.ex index adfada1..5ebf272 100644 --- a/lib/acx/enforcer_server.ex +++ b/lib/acx/enforcer_server.ex @@ -68,6 +68,18 @@ defmodule Acx.EnforcerServer do GenServer.call(via_tuple(ename), {:load_policies, pfile}) end + @doc """ + Loads policy rules from the configured persist adapter and adds them + to the enforcer. This includes both regular policies and mapping policies. + + The adapter must be set using `set_persist_adapter/2` before calling this function. + + See `Enforcer.load_policies!/1` and `Enforcer.load_mapping_policies!/1` for more details. + """ + def load_policies_from_adapter(ename) do + GenServer.call(via_tuple(ename), {:load_policies_from_adapter}) + end + @doc """ Returns a list of policies in the given enforcer that match the given criteria. @@ -245,6 +257,16 @@ defmodule Acx.EnforcerServer do {:reply, :ok, new_enforcer} end + def handle_call({:load_policies_from_adapter}, _from, enforcer) do + new_enforcer = + enforcer + |> Enforcer.load_policies!() + |> Enforcer.load_mapping_policies!() + + :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/test/persist/load_policies_from_adapter_test.exs b/test/persist/load_policies_from_adapter_test.exs new file mode 100644 index 0000000..142a7ee --- /dev/null +++ b/test/persist/load_policies_from_adapter_test.exs @@ -0,0 +1,133 @@ +defmodule Acx.Persist.LoadPoliciesFromAdapterTest do + use ExUnit.Case, async: true + alias Acx.Enforcer + alias Acx.EnforcerServer + alias Acx.Persist.EctoAdapter + + @cfile "../data/rbac.conf" |> Path.expand(__DIR__) + + defmodule MockRepo do + use Acx.Persist.MockRepo, pfile: "../data/rbac.csv" |> Path.expand(__DIR__) + end + + @repo MockRepo + @enforcer_name :test_load_from_adapter + + setup do + # Start the enforcer with a model but no policies + {:ok, _pid} = EnforcerServer.start_link(@enforcer_name, @cfile) + + # Set the persist adapter + adapter = EctoAdapter.new(@repo) + :ok = EnforcerServer.set_persist_adapter(@enforcer_name, adapter) + + on_exit(fn -> + # Clean up the enforcer process if it's still running + case Process.whereis({:via, Registry, {Acx.EnforcerRegistry, @enforcer_name}}) do + nil -> :ok + pid -> Process.exit(pid, :kill) + end + end) + + {:ok, adapter: adapter} + end + + describe "EnforcerServer.load_policies_from_adapter/1" do + test "loads policies and mapping policies from the adapter" do + # Before loading, policies should not work + refute EnforcerServer.allow?(@enforcer_name, ["alice", "blog_post", "delete"]) + + # Load policies from adapter + :ok = EnforcerServer.load_policies_from_adapter(@enforcer_name) + + # After loading, policies should work + assert EnforcerServer.allow?(@enforcer_name, ["alice", "blog_post", "delete"]) + assert EnforcerServer.allow?(@enforcer_name, ["peter", "blog_post", "create"]) + assert EnforcerServer.allow?(@enforcer_name, ["bob", "blog_post", "read"]) + + # Check that mapping policies were loaded too (bob has reader role) + refute EnforcerServer.allow?(@enforcer_name, ["bob", "blog_post", "create"]) + end + + test "lists loaded policies after loading from adapter" do + # Load policies from adapter + :ok = EnforcerServer.load_policies_from_adapter(@enforcer_name) + + # Check that policies are present + policies = EnforcerServer.list_policies(@enforcer_name, %{key: :p}) + assert length(policies) > 0 + + # Verify specific policy exists + alice_delete_policy = + Enum.find(policies, fn policy -> + policy.attrs[:sub] == "alice" and + policy.attrs[:obj] == "blog_post" and + policy.attrs[:act] == "delete" + end) + + assert alice_delete_policy != nil + end + + test "works with enforcer lifecycle (startup simulation)" do + # Simulate application startup where we: + # 1. Start enforcer + # 2. Set adapter + # 3. Load policies from database + + # Adapter was already set in setup + # Now load policies as you would on startup + :ok = EnforcerServer.load_policies_from_adapter(@enforcer_name) + + # Verify that all expected policies work + test_cases = [ + {["bob", "blog_post", "read"], true}, + {["bob", "blog_post", "create"], false}, + {["peter", "blog_post", "read"], true}, + {["peter", "blog_post", "create"], true}, + {["alice", "blog_post", "delete"], true} + ] + + Enum.each(test_cases, fn {req, expected} -> + assert EnforcerServer.allow?(@enforcer_name, req) === expected + end) + end + end + + describe "Enforcer.load_policies!/1 with adapter" do + test "loads policies from configured adapter" do + adapter = EctoAdapter.new(@repo) + {:ok, enforcer} = Enforcer.init(@cfile, adapter) + + # Before loading, no policies + assert enforcer.policies == [] + + # Load policies from adapter + enforcer = Enforcer.load_policies!(enforcer) + + # After loading, policies exist + assert length(enforcer.policies) > 0 + end + + test "loads both policies and mapping policies" do + adapter = EctoAdapter.new(@repo) + {:ok, enforcer} = Enforcer.init(@cfile, adapter) + + # Load both policies and mapping policies + enforcer = + enforcer + |> Enforcer.load_policies!() + |> Enforcer.load_mapping_policies!() + + # Verify policies are loaded + assert length(enforcer.policies) > 0 + + # Verify mapping policies are loaded + assert length(enforcer.mapping_policies) > 0 + + # Test that role-based access works + assert Enforcer.allow?(enforcer, ["alice", "blog_post", "delete"]) + assert Enforcer.allow?(enforcer, ["bob", "blog_post", "read"]) + refute Enforcer.allow?(enforcer, ["bob", "blog_post", "create"]) + end + end +end From c75137af5dea16ea45a8320b9f3606740852b01a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:59:15 +0000 Subject: [PATCH 3/5] Add documentation for load_policies_from_adapter feature Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- README.md | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/README.md b/README.md index a5ca447..1bb0d9b 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,111 @@ case EnforcerServer.allow?(ename, new_req) do end ``` +## Persistence Adapters + +Casbin-Ex supports persisting policies to a database using adapters. The `EctoAdapter` allows you to store and load policies from any Ecto-compatible database. + +### Setting up the EctoAdapter + +First, create a migration for the `casbin_rule` table: + +```elixir +defmodule MyApp.Repo.Migrations.CreateCasbinRule do + use Ecto.Migration + + def change do + create table(:casbin_rule) do + add :ptype, :string, null: false + add :v0, :string + add :v1, :string + add :v2, :string + add :v3, :string + add :v4, :string + add :v5, :string + add :v6, :string + end + + create index(:casbin_rule, [:ptype]) + end +end +``` + +### Using the EctoAdapter with EnforcerServer + +```elixir +alias Acx.{EnforcerSupervisor, EnforcerServer} +alias Acx.Persist.EctoAdapter + +# Start the enforcer +ename = "my_enforcer" +EnforcerSupervisor.start_enforcer(ename, "path/to/model.conf") + +# Configure the persist adapter +adapter = EctoAdapter.new(MyApp.Repo) +EnforcerServer.set_persist_adapter(ename, adapter) + +# Load policies from database on application startup +EnforcerServer.load_policies_from_adapter(ename) + +# Add a new policy - it will be automatically saved to the database +EnforcerServer.add_policy(ename, {:p, ["alice", "data", "write"]}) + +# The policy is now both in memory and persisted to the database +EnforcerServer.allow?(ename, ["alice", "data", "write"]) +# => true +``` + +### Loading Policies on Application Startup + +When your application restarts, policies need to be loaded from the database back into memory: + +```elixir +# In your application startup code +def start(_type, _args) do + children = [ + MyApp.Repo, + {Acx.EnforcerSupervisor, []} + ] + + opts = [strategy: :one_for_one, name: MyApp.Supervisor] + {:ok, _pid} = Supervisor.start_link(children, opts) + + # Initialize enforcer and load policies from database + ename = "my_enforcer" + EnforcerSupervisor.start_enforcer(ename, "path/to/model.conf") + + adapter = EctoAdapter.new(MyApp.Repo) + EnforcerServer.set_persist_adapter(ename, adapter) + + # Load all policies from database into memory + EnforcerServer.load_policies_from_adapter(ename) + + {:ok, pid} +end +``` + +### Using the EctoAdapter with Enforcer directly + +You can also use the adapter directly without EnforcerServer: + +```elixir +alias Acx.Enforcer +alias Acx.Persist.EctoAdapter + +# Initialize enforcer with adapter +adapter = EctoAdapter.new(MyApp.Repo) +{:ok, enforcer} = Enforcer.init("path/to/model.conf", adapter) + +# Load policies from database +enforcer = + enforcer + |> Enforcer.load_policies!() + |> Enforcer.load_mapping_policies!() + +# Use the enforcer +Enforcer.allow?(enforcer, ["alice", "data", "write"]) +``` + ## Supported Models Casbin-Ex supports the following access control models: From fb30f52d1afd0a73dd33e4b9320368c4fcb94933 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:01:11 +0000 Subject: [PATCH 4/5] Add error handling test for load_policies_from_adapter Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- test/persist/load_policies_from_adapter_test.exs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/persist/load_policies_from_adapter_test.exs b/test/persist/load_policies_from_adapter_test.exs index 142a7ee..df86eab 100644 --- a/test/persist/load_policies_from_adapter_test.exs +++ b/test/persist/load_policies_from_adapter_test.exs @@ -94,6 +94,14 @@ defmodule Acx.Persist.LoadPoliciesFromAdapterTest do end describe "Enforcer.load_policies!/1 with adapter" do + test "returns error when no adapter is set" do + {:ok, enforcer} = Enforcer.init(@cfile) + + # Trying to load without adapter should return error + result = Enforcer.load_policies!(enforcer) + assert result == {:error, "No adapter set and no policy file provided"} + end + test "loads policies from configured adapter" do adapter = EctoAdapter.new(@repo) {:ok, enforcer} = Enforcer.init(@cfile, adapter) From b1438701b0018a723fa1f1119807077290087457 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:28:00 +0000 Subject: [PATCH 5/5] Fix test failures in load_policies_from_adapter_test.exs - Change async: true to async: false to prevent race conditions - Use unique enforcer names per test to avoid conflicts - Fix cleanup function to use GenServer.stop with proper via tuple - Update error test to match actual behavior (returns empty enforcer, not error) - Update policy assertions to match rbac.csv data (admin not alice) Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com> --- .../load_policies_from_adapter_test.exs | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/test/persist/load_policies_from_adapter_test.exs b/test/persist/load_policies_from_adapter_test.exs index df86eab..6197012 100644 --- a/test/persist/load_policies_from_adapter_test.exs +++ b/test/persist/load_policies_from_adapter_test.exs @@ -1,5 +1,5 @@ defmodule Acx.Persist.LoadPoliciesFromAdapterTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias Acx.Enforcer alias Acx.EnforcerServer alias Acx.Persist.EctoAdapter @@ -11,56 +11,59 @@ defmodule Acx.Persist.LoadPoliciesFromAdapterTest do end @repo MockRepo - @enforcer_name :test_load_from_adapter setup do + # Generate a unique enforcer name for each test to avoid conflicts + enforcer_name = :"test_load_from_adapter_#{:erlang.unique_integer([:positive])}" + # Start the enforcer with a model but no policies - {:ok, _pid} = EnforcerServer.start_link(@enforcer_name, @cfile) + {:ok, _pid} = EnforcerServer.start_link(enforcer_name, @cfile) # Set the persist adapter adapter = EctoAdapter.new(@repo) - :ok = EnforcerServer.set_persist_adapter(@enforcer_name, adapter) + :ok = EnforcerServer.set_persist_adapter(enforcer_name, adapter) on_exit(fn -> # Clean up the enforcer process if it's still running - case Process.whereis({:via, Registry, {Acx.EnforcerRegistry, @enforcer_name}}) do - nil -> :ok - pid -> Process.exit(pid, :kill) + try do + GenServer.stop({:via, Registry, {Acx.EnforcerRegistry, enforcer_name}}, :normal, 100) + catch + :exit, _ -> :ok end end) - {:ok, adapter: adapter} + {:ok, adapter: adapter, enforcer_name: enforcer_name} end describe "EnforcerServer.load_policies_from_adapter/1" do - test "loads policies and mapping policies from the adapter" do + test "loads policies and mapping policies from the adapter", %{enforcer_name: enforcer_name} do # Before loading, policies should not work - refute EnforcerServer.allow?(@enforcer_name, ["alice", "blog_post", "delete"]) + refute EnforcerServer.allow?(enforcer_name, ["alice", "blog_post", "delete"]) # Load policies from adapter - :ok = EnforcerServer.load_policies_from_adapter(@enforcer_name) + :ok = EnforcerServer.load_policies_from_adapter(enforcer_name) # After loading, policies should work - assert EnforcerServer.allow?(@enforcer_name, ["alice", "blog_post", "delete"]) - assert EnforcerServer.allow?(@enforcer_name, ["peter", "blog_post", "create"]) - assert EnforcerServer.allow?(@enforcer_name, ["bob", "blog_post", "read"]) + assert EnforcerServer.allow?(enforcer_name, ["alice", "blog_post", "delete"]) + assert EnforcerServer.allow?(enforcer_name, ["peter", "blog_post", "create"]) + assert EnforcerServer.allow?(enforcer_name, ["bob", "blog_post", "read"]) # Check that mapping policies were loaded too (bob has reader role) - refute EnforcerServer.allow?(@enforcer_name, ["bob", "blog_post", "create"]) + refute EnforcerServer.allow?(enforcer_name, ["bob", "blog_post", "create"]) end - test "lists loaded policies after loading from adapter" do + test "lists loaded policies after loading from adapter", %{enforcer_name: enforcer_name} do # Load policies from adapter - :ok = EnforcerServer.load_policies_from_adapter(@enforcer_name) + :ok = EnforcerServer.load_policies_from_adapter(enforcer_name) # Check that policies are present - policies = EnforcerServer.list_policies(@enforcer_name, %{key: :p}) + policies = EnforcerServer.list_policies(enforcer_name, %{key: :p}) assert length(policies) > 0 # Verify specific policy exists alice_delete_policy = Enum.find(policies, fn policy -> - policy.attrs[:sub] == "alice" and + policy.attrs[:sub] == "admin" and policy.attrs[:obj] == "blog_post" and policy.attrs[:act] == "delete" end) @@ -68,7 +71,7 @@ defmodule Acx.Persist.LoadPoliciesFromAdapterTest do assert alice_delete_policy != nil end - test "works with enforcer lifecycle (startup simulation)" do + test "works with enforcer lifecycle (startup simulation)", %{enforcer_name: enforcer_name} do # Simulate application startup where we: # 1. Start enforcer # 2. Set adapter @@ -76,7 +79,7 @@ defmodule Acx.Persist.LoadPoliciesFromAdapterTest do # Adapter was already set in setup # Now load policies as you would on startup - :ok = EnforcerServer.load_policies_from_adapter(@enforcer_name) + :ok = EnforcerServer.load_policies_from_adapter(enforcer_name) # Verify that all expected policies work test_cases = [ @@ -88,18 +91,21 @@ defmodule Acx.Persist.LoadPoliciesFromAdapterTest do ] Enum.each(test_cases, fn {req, expected} -> - assert EnforcerServer.allow?(@enforcer_name, req) === expected + assert EnforcerServer.allow?(enforcer_name, req) === expected end) end end describe "Enforcer.load_policies!/1 with adapter" do - test "returns error when no adapter is set" do + test "loads empty list when no adapter with policy file is set" do {:ok, enforcer} = Enforcer.init(@cfile) - # Trying to load without adapter should return error - result = Enforcer.load_policies!(enforcer) - assert result == {:error, "No adapter set and no policy file provided"} + # Enforcer.init sets a ReadonlyFileAdapter with policy_file: nil + # load_policies! will return an empty list, not an error + enforcer = Enforcer.load_policies!(enforcer) + + # Should have no policies loaded + assert enforcer.policies == [] end test "loads policies from configured adapter" do