Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions lib/acx/enforcer_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand Down
147 changes: 147 additions & 0 deletions test/persist/load_policies_from_adapter_test.exs
Original file line number Diff line number Diff line change
@@ -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