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: 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..6197012 --- /dev/null +++ b/test/persist/load_policies_from_adapter_test.exs @@ -0,0 +1,147 @@ +defmodule Acx.Persist.LoadPoliciesFromAdapterTest do + use ExUnit.Case, async: false + 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 + + 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) + + # 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 + try do + GenServer.stop({:via, Registry, {Acx.EnforcerRegistry, enforcer_name}}, :normal, 100) + catch + :exit, _ -> :ok + end + end) + + {: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", %{enforcer_name: enforcer_name} 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", %{enforcer_name: enforcer_name} 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] == "admin" 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)", %{enforcer_name: enforcer_name} 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 empty list when no adapter with policy file is set" do + {:ok, enforcer} = Enforcer.init(@cfile) + + # 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 + 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