diff --git a/LOADING_POLICIES_FROM_DATABASE.md b/LOADING_POLICIES_FROM_DATABASE.md new file mode 100644 index 0000000..91c4d2d --- /dev/null +++ b/LOADING_POLICIES_FROM_DATABASE.md @@ -0,0 +1,236 @@ +# Loading Policies from Database Adapter + +This guide demonstrates how to use the new `load_policies/1` and `load_mapping_policies/1` functions to load policies from a database adapter (such as EctoAdapter) on application startup. + +## The Problem + +Previously, the EctoAdapter would automatically save policies to the database but provided no clean way to load them back into the enforcer's memory on application startup. This required developers to implement manual workarounds. + +## The Solution + +We've added overloaded versions of `EnforcerServer.load_policies/1` and `EnforcerServer.load_mapping_policies/1` that load policies from the configured persist adapter instead of requiring a file path. + +## Usage Example + +### Basic Setup with EctoAdapter + +```elixir +alias Acx.{EnforcerSupervisor, EnforcerServer} +alias Acx.Persist.EctoAdapter + +# 1. Start the enforcer with your model configuration +ename = "my_enforcer" +EnforcerSupervisor.start_enforcer(ename, "path/to/model.conf") + +# 2. Configure the database adapter +adapter = EctoAdapter.new(MyApp.Repo) +EnforcerServer.set_persist_adapter(ename, adapter) + +# 3. Load policies from the database +EnforcerServer.load_policies(ename) + +# 4. Load mapping policies (for RBAC) from the database +EnforcerServer.load_mapping_policies(ename) + +# Now your enforcer is ready to use! +EnforcerServer.allow?(ename, ["alice", "blog_post", "read"]) +``` + +### Application Startup Integration + +Here's how to integrate this into your Phoenix application's startup sequence: + +```elixir +defmodule MyApp.Application do + use Application + + def start(_type, _args) do + children = [ + # Your repo + MyApp.Repo, + + # Other children... + + # Start the enforcer supervisor + {Acx.EnforcerSupervisor, []} + ] + + opts = [strategy: :one_for_one, name: MyApp.Supervisor] + result = Supervisor.start_link(children, opts) + + # Initialize the enforcer after the supervisor starts + initialize_enforcer() + + result + end + + defp initialize_enforcer do + alias Acx.{EnforcerSupervisor, EnforcerServer} + alias Acx.Persist.EctoAdapter + + # Model configuration path + model_path = Application.app_dir(:my_app, "priv/casbin/model.conf") + + # Start the enforcer + ename = "my_app_enforcer" + EnforcerSupervisor.start_enforcer(ename, model_path) + + # Set up database adapter + adapter = EctoAdapter.new(MyApp.Repo) + EnforcerServer.set_persist_adapter(ename, adapter) + + # Load policies from database + EnforcerServer.load_policies(ename) + EnforcerServer.load_mapping_policies(ename) + end +end +``` + +### Adding Policies at Runtime + +Once configured, new policies are automatically persisted: + +```elixir +# Add a policy - automatically saved to database +EnforcerServer.add_policy("my_enforcer", {:p, ["admin", "data", "write"]}) + +# Add a role mapping - automatically saved to database +EnforcerServer.add_mapping_policy("my_enforcer", {:g, "alice", "admin"}) + +# On next application restart, these policies will be loaded automatically +``` + +### Working with Filtered Policies + +You can also load only specific policies based on filters: + +```elixir +# Load only policies for a specific domain +filter = %{v3: "org:tenant_123"} +EnforcerServer.load_filtered_policies("my_enforcer", filter) + +# Load policies with multiple criteria +filter = %{ptype: "p", v3: ["org:tenant_1", "org:tenant_2"]} +EnforcerServer.load_filtered_policies("my_enforcer", filter) +``` + +## API Reference + +### EnforcerServer.load_policies/1 + +Loads all policies from the configured persist adapter. + +**Parameters:** +- `ename` - The name of the enforcer + +**Returns:** `:ok` + +**Example:** +```elixir +EnforcerServer.load_policies("my_enforcer") +``` + +### EnforcerServer.load_policies/2 + +Loads policies from a CSV file (original behavior, still supported). + +**Parameters:** +- `ename` - The name of the enforcer +- `pfile` - Path to the policy CSV file + +**Returns:** `:ok` + +**Example:** +```elixir +EnforcerServer.load_policies("my_enforcer", "path/to/policies.csv") +``` + +### EnforcerServer.load_mapping_policies/1 + +Loads all mapping policies (role assignments) from the configured persist adapter. + +**Parameters:** +- `ename` - The name of the enforcer + +**Returns:** `:ok` + +**Example:** +```elixir +EnforcerServer.load_mapping_policies("my_enforcer") +``` + +### EnforcerServer.load_mapping_policies/2 + +Loads mapping policies from a CSV file (original behavior, still supported). + +**Parameters:** +- `ename` - The name of the enforcer +- `fname` - Path to the mapping policies CSV file + +**Returns:** `:ok` + +**Example:** +```elixir +EnforcerServer.load_mapping_policies("my_enforcer", "path/to/mappings.csv") +``` + +## Migration Guide + +### Before (Manual Workaround) + +```elixir +defp load_policies_from_db do + rules = Repo.all(Acx.Persist.EctoAdapter.CasbinRule) + + Enum.each(rules, fn rule -> + case rule.ptype do + "p" -> + attrs = build_attrs([rule.v0, rule.v1, rule.v2, rule.v3, rule.v4, rule.v5, rule.v6]) + EnforcerServer.add_policy(@enforcer_name, {:p, attrs}) + + "g" -> + attrs = build_attrs([rule.v0, rule.v1, rule.v2]) + case length(attrs) do + 3 -> + [child, parent, domain] = attrs + EnforcerServer.add_mapping_policy(@enforcer_name, {:g, child, parent, domain}) + 2 -> + [child, parent] = attrs + EnforcerServer.add_mapping_policy(@enforcer_name, {:g, child, parent}) + end + end + end) +end + +defp build_attrs(values) do + Enum.reject(values, &is_nil/1) +end +``` + +### After (Clean API) + +```elixir +# Set up adapter +adapter = EctoAdapter.new(Repo) +EnforcerServer.set_persist_adapter("my_enforcer", adapter) + +# Load all policies from database +EnforcerServer.load_policies("my_enforcer") +EnforcerServer.load_mapping_policies("my_enforcer") +``` + +## Backward Compatibility + +The new functions are fully backward compatible: + +- `load_policies/2` still works with file paths +- `load_mapping_policies/2` still works with file paths +- All existing code continues to work without modification +- The new `/1` variants are optional and can be adopted gradually + +## Related Functions + +- `EnforcerServer.set_persist_adapter/2` - Configure the adapter +- `EnforcerServer.load_filtered_policies/2` - Load with filters +- `EnforcerServer.save_policies/1` - Save all policies to adapter +- `Enforcer.load_policies!/1` - Low-level API for direct enforcer usage diff --git a/README.md b/README.md index a5ca447..58a368c 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,77 @@ case EnforcerServer.allow?(ename, new_req) do end ``` +## Database Persistence with EctoAdapter + +Casbin-Ex supports persisting policies to a database using the EctoAdapter. This is particularly useful for production applications where policies need to be managed dynamically. + +### Setup + +```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 database adapter +adapter = EctoAdapter.new(MyApp.Repo) +EnforcerServer.set_persist_adapter(ename, adapter) + +# Load existing policies from the database +EnforcerServer.load_policies(ename) +EnforcerServer.load_mapping_policies(ename) +``` + +### Runtime Policy Management + +Once configured, policies are automatically persisted to the database: + +```elixir +# Add a policy - automatically saved to database +EnforcerServer.add_policy(ename, {:p, ["admin", "data", "write"]}) + +# Add a role mapping - automatically saved to database +EnforcerServer.add_mapping_policy(ename, {:g, "alice", "admin"}) + +# Remove a policy - automatically removed from database +EnforcerServer.remove_policy(ename, {:p, ["admin", "data", "write"]}) +``` + +### Application Startup + +On application restart, simply load policies from the database: + +```elixir +defmodule MyApp.Application do + use Application + + def start(_type, _args) do + # ... your supervision tree ... + + # Initialize enforcer on startup + initialize_enforcer() + end + + defp initialize_enforcer do + ename = "my_app_enforcer" + model_path = Application.app_dir(:my_app, "priv/casbin/model.conf") + + EnforcerSupervisor.start_enforcer(ename, model_path) + + adapter = EctoAdapter.new(MyApp.Repo) + EnforcerServer.set_persist_adapter(ename, adapter) + + # Load all policies from database + EnforcerServer.load_policies(ename) + EnforcerServer.load_mapping_policies(ename) + end +end +``` + +For more details, see [LOADING_POLICIES_FROM_DATABASE.md](LOADING_POLICIES_FROM_DATABASE.md). + ## 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 b568ab6..920a2d6 100644 --- a/lib/acx/enforcer_server.ex +++ b/lib/acx/enforcer_server.ex @@ -58,6 +58,28 @@ defmodule Acx.EnforcerServer do GenServer.call(via_tuple(ename), {:remove_policy, {key, attrs}}) end + @doc """ + Loads policy rules from the configured persist adapter and adds them + to the enforcer. + + This function is useful for loading policies from a database adapter + (like EctoAdapter) on application startup. + + See `Enforcer.load_policies!/1` for more details. + + ## Examples + + # Set up the adapter + adapter = EctoAdapter.new(Repo) + EnforcerServer.set_persist_adapter("my_enforcer", adapter) + + # Load policies from the adapter + EnforcerServer.load_policies("my_enforcer") + """ + def load_policies(ename) do + GenServer.call(via_tuple(ename), {:load_policies}) + end + @doc """ Loads policy rules from external file given by the name `pfile` and adds them to the enforcer. @@ -164,6 +186,28 @@ defmodule Acx.EnforcerServer do ) end + @doc """ + Loads mapping policies from the configured persist adapter and adds them + to the enforcer. + + This function is useful for loading mapping policies (role assignments) + from a database adapter (like EctoAdapter) on application startup. + + See `Enforcer.load_mapping_policies!/1` for more details. + + ## Examples + + # Set up the adapter + adapter = EctoAdapter.new(Repo) + EnforcerServer.set_persist_adapter("my_enforcer", adapter) + + # Load mapping policies from the adapter + EnforcerServer.load_mapping_policies("my_enforcer") + """ + def load_mapping_policies(ename) do + GenServer.call(via_tuple(ename), {:load_mapping_policies}) + end + @doc """ Loads mapping policies from a csv file and adds them to the enforcer. @@ -249,6 +293,12 @@ defmodule Acx.EnforcerServer do end end + def handle_call({:load_policies}, _from, enforcer) do + new_enforcer = enforcer |> Enforcer.load_policies!() + :ets.insert(:enforcers_table, {self_name(), new_enforcer}) + {:reply, :ok, new_enforcer} + end + def handle_call({:load_policies, pfile}, _from, enforcer) do new_enforcer = enforcer |> Enforcer.load_policies!(pfile) :ets.insert(:enforcers_table, {self_name(), new_enforcer}) @@ -286,6 +336,12 @@ defmodule Acx.EnforcerServer do end end + def handle_call({:load_mapping_policies}, _from, enforcer) do + new_enforcer = enforcer |> Enforcer.load_mapping_policies!() + :ets.insert(:enforcers_table, {self_name(), new_enforcer}) + {:reply, :ok, new_enforcer} + end + def handle_call({:load_mapping_policies, fname}, _from, enforcer) do new_enforcer = enforcer |> Enforcer.load_mapping_policies!(fname) :ets.insert(:enforcers_table, {self_name(), new_enforcer}) diff --git a/test/enforcer_server_adapter_test.exs b/test/enforcer_server_adapter_test.exs new file mode 100644 index 0000000..f42f444 --- /dev/null +++ b/test/enforcer_server_adapter_test.exs @@ -0,0 +1,134 @@ +defmodule Acx.EnforcerServerAdapterTest do + use ExUnit.Case, async: true + alias Acx.{EnforcerSupervisor, EnforcerServer} + alias Acx.Persist.EctoAdapter + + @cfile "../test/data/rbac.conf" |> Path.expand(__DIR__) + + defmodule MockRbacRepo do + use Acx.Persist.MockRepo, pfile: "../test/data/rbac.csv" |> Path.expand(__DIR__) + end + + @repo MockRbacRepo + + setup do + ename = "test_enforcer_#{:erlang.unique_integer([:positive])}" + EnforcerSupervisor.start_enforcer(ename, @cfile) + + on_exit(fn -> + try do + GenServer.stop(via_tuple(ename)) + catch + :exit, _ -> :ok + end + end) + + {:ok, ename: ename} + end + + defp via_tuple(ename) do + {:via, Registry, {Acx.EnforcerRegistry, ename}} + end + + describe "load_policies/1 with EctoAdapter" do + test "loads policies from database adapter on startup", %{ename: ename} do + # Set up the adapter + adapter = EctoAdapter.new(@repo) + :ok = EnforcerServer.set_persist_adapter(ename, adapter) + + # Load policies from the adapter (no file path needed) + :ok = EnforcerServer.load_policies(ename) + + # Verify policies are loaded by checking authorization + assert EnforcerServer.allow?(ename, ["alice", "blog_post", "read"]) === false + assert EnforcerServer.allow?(ename, ["bob", "blog_post", "read"]) === false + + # Now load mapping policies to complete the RBAC setup + :ok = EnforcerServer.load_mapping_policies(ename) + + # Verify RBAC is working correctly + assert EnforcerServer.allow?(ename, ["bob", "blog_post", "read"]) === true + assert EnforcerServer.allow?(ename, ["bob", "blog_post", "create"]) === false + assert EnforcerServer.allow?(ename, ["peter", "blog_post", "read"]) === true + assert EnforcerServer.allow?(ename, ["peter", "blog_post", "create"]) === true + assert EnforcerServer.allow?(ename, ["alice", "blog_post", "delete"]) === true + end + + test "policies persist across adapter operations", %{ename: ename} do + # Set up the adapter + adapter = EctoAdapter.new(@repo) + :ok = EnforcerServer.set_persist_adapter(ename, adapter) + + # Load initial policies and mappings + :ok = EnforcerServer.load_policies(ename) + :ok = EnforcerServer.load_mapping_policies(ename) + + # Add a new policy (should be saved to adapter) + :ok = EnforcerServer.add_policy(ename, {:p, ["guest", "blog_post", "read"]}) + + # Verify the new policy works + assert EnforcerServer.allow?(ename, ["guest", "blog_post", "read"]) === true + + # List policies to verify it was added + policies = EnforcerServer.list_policies(ename, %{sub: "guest"}) + assert length(policies) === 1 + assert hd(policies).key === :p + end + end + + describe "load_mapping_policies/1 with EctoAdapter" do + test "loads mapping policies from database adapter", %{ename: ename} do + # Set up the adapter + adapter = EctoAdapter.new(@repo) + :ok = EnforcerServer.set_persist_adapter(ename, adapter) + + # Load policies first + :ok = EnforcerServer.load_policies(ename) + + # Then load mapping policies from adapter + :ok = EnforcerServer.load_mapping_policies(ename) + + # Verify role inheritance is working + assert EnforcerServer.allow?(ename, ["bob", "blog_post", "read"]) === true + assert EnforcerServer.allow?(ename, ["peter", "blog_post", "read"]) === true + assert EnforcerServer.allow?(ename, ["alice", "blog_post", "read"]) === true + end + + test "works without loading policies first", %{ename: ename} do + # Set up the adapter + adapter = EctoAdapter.new(@repo) + :ok = EnforcerServer.set_persist_adapter(ename, adapter) + + # Load only mapping policies + :ok = EnforcerServer.load_mapping_policies(ename) + + # Mappings should be loaded (though without policies they won't grant access) + # Just verify the function doesn't crash + assert EnforcerServer.allow?(ename, ["bob", "blog_post", "read"]) === false + end + end + + describe "backward compatibility" do + test "load_policies/2 still works with file path", %{ename: ename} do + pfile = "../test/data/acl.csv" |> Path.expand(__DIR__) + + # Load policies from file (old behavior) + :ok = EnforcerServer.load_policies(ename, pfile) + + # Verify policies are loaded + assert EnforcerServer.allow?(ename, ["alice", "blog_post", "read"]) === true + assert EnforcerServer.allow?(ename, ["alice", "blog_post", "create"]) === true + end + + test "load_mapping_policies/2 still works with file path", %{ename: ename} do + pfile = "../test/data/rbac.csv" |> Path.expand(__DIR__) + + # Load policies and mappings from file (old behavior) + :ok = EnforcerServer.load_policies(ename, pfile) + :ok = EnforcerServer.load_mapping_policies(ename, pfile) + + # Verify RBAC works + assert EnforcerServer.allow?(ename, ["bob", "blog_post", "read"]) === true + end + end +end