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
98 changes: 98 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,104 @@ case EnforcerServer.allow?(ename, new_req) do
end
```

## Database Persistence

Acx supports persisting policies to a database using the `EctoAdapter`. This allows you to store policies in a database and load them back into memory on application startup.

### Setup

First, add the `casbin_rule` table to your database:

```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
```

### Usage

```elixir
alias Acx.{EnforcerServer, Persist.EctoAdapter}

# Start your enforcer with a model configuration
{:ok, _pid} = EnforcerServer.start_link("my_enforcer", "path/to/model.conf")

# Configure the database adapter
adapter = EctoAdapter.new(MyApp.Repo)
:ok = EnforcerServer.set_persist_adapter("my_enforcer", adapter)

# Load policies from the database
# This loads both regular policies and role mappings
:ok = EnforcerServer.load_policies_from_adapter("my_enforcer")

# Now your enforcer is ready with policies from the database
EnforcerServer.allow?("my_enforcer", ["alice", "blog_post", "read"])
# => true or false based on policies in the database
```

### Automatic Persistence

When you add or remove policies, they are automatically saved to the database:

```elixir
# This automatically saves to the database
EnforcerServer.add_policy("my_enforcer", {:p, ["admin", "data", "write"]})

# This also automatically saves to the database
EnforcerServer.add_mapping_policy("my_enforcer", {:g, "alice", "admin"})
```

### Application Startup Pattern

A typical pattern for loading policies on application startup:

```elixir
defmodule MyApp.Application do
use Application

def start(_type, _args) do
children = [
MyApp.Repo,
# ... other children
{Acx.EnforcerSupervisor, []},
]

opts = [strategy: :one_for_one, name: MyApp.Supervisor]
result = Supervisor.start_link(children, opts)

# Initialize the enforcer after the repo is started
setup_enforcer()

result
end

defp setup_enforcer do
{:ok, _pid} = Acx.EnforcerServer.start_link("my_enforcer", "priv/model.conf")

adapter = Acx.Persist.EctoAdapter.new(MyApp.Repo)
:ok = Acx.EnforcerServer.set_persist_adapter("my_enforcer", adapter)

# Load policies from database on startup
:ok = Acx.EnforcerServer.load_policies_from_adapter("my_enforcer")
end
end
```

## TODO

### Global
Expand Down
50 changes: 38 additions & 12 deletions lib/acx/enforcer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -319,14 +319,12 @@ defmodule Acx.Enforcer do
}
]
"""
@spec load_policies!(t(), String.t()) :: t()
def load_policies!(%__MODULE__{model: m} = enforcer, pfile)
when is_binary(pfile) do
adapter = ReadonlyFileAdapter.new(pfile)
enforcer = %{enforcer | persist_adapter: adapter}

@spec load_policies!(t()) :: t() | {:error, any()}
def load_policies!(%__MODULE__{persist_adapter: nil}) do
{:error, "No adapter set and no policy file provided"}
end

@spec load_policies!(t()) :: t() | {:error, any()}
def load_policies!(%__MODULE__{model: m, persist_adapter: adapter} = enforcer) do
case PersistAdapter.load_policies(adapter) do
{:ok, policies} ->
policies
Expand All @@ -337,12 +335,40 @@ defmodule Acx.Enforcer do
end
end

@spec load_policies!(t(), String.t()) :: t()
def load_policies!(%__MODULE__{model: m} = enforcer, pfile)
when is_binary(pfile) do
adapter = ReadonlyFileAdapter.new(pfile)
enforcer = %{enforcer | persist_adapter: adapter}
@doc """
Loads policy rules from the configured persist adapter and adds them
to the enforcer.

This is useful for loading policies from a database or other storage
backend after application startup. The adapter must be configured using
`set_persist_adapter/2` or by passing it to `init/2`.

Returns the updated enforcer with policies loaded from the adapter, or
`{:error, reason}` if no adapter is configured.

## Examples

# Initialize enforcer with adapter
adapter = Acx.Persist.EctoAdapter.new(MyRepo)
{:ok, e} = Enforcer.init("path/to/model.conf", adapter)

# Load policies from the adapter
e = Enforcer.load_policies!(e)

# Now the enforcer has policies from the database
Enforcer.allow?(e, ["alice", "blog_post", "read"])
# => true

See also `load_policies!/2` for loading from a file, and
`load_mapping_policies!/1` for loading role mappings from the adapter.
"""
@spec load_policies!(t()) :: t() | {:error, any()}
def load_policies!(%__MODULE__{persist_adapter: nil}) do
{:error, "No adapter set and no policy file provided"}
end

@spec load_policies!(t()) :: t() | {:error, any()}
def load_policies!(%__MODULE__{model: m, persist_adapter: adapter} = enforcer) do
case PersistAdapter.load_policies(adapter) do
{:ok, policies} ->
policies
Expand Down
43 changes: 43 additions & 0 deletions lib/acx/enforcer_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,33 @@ 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 function loads both regular policies and mapping policies (role
inheritances) from the database into the enforcer's memory.

Returns `:ok` on success, or `{:error, reason}` if the adapter is not
configured or if loading fails.

## Examples

# Configure adapter
adapter = EctoAdapter.new(Repo)
EnforcerServer.set_persist_adapter("my_enforcer", adapter)

# Load policies from the adapter
EnforcerServer.load_policies_from_adapter("my_enforcer")
# => :ok

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 +272,22 @@ defmodule Acx.EnforcerServer do
{:reply, :ok, new_enforcer}
end

def handle_call({:load_policies_from_adapter}, _from, enforcer) do
try do
case Enforcer.load_policies!(enforcer) do
{:error, reason} ->
{:reply, {:error, reason}, enforcer}

new_enforcer ->
new_enforcer = Enforcer.load_mapping_policies!(new_enforcer)
:ets.insert(:enforcers_table, {self_name(), new_enforcer})
{:reply, :ok, new_enforcer}
end
rescue
e -> {:reply, {:error, Exception.message(e)}, enforcer}
end
end

def handle_call({:list_policies, criteria}, _from, enforcer) do
policies = enforcer |> Enforcer.list_policies(criteria)
{:reply, policies, enforcer}
Expand Down
129 changes: 129 additions & 0 deletions test/persist/ecto_adapter_load_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
defmodule Acx.Persist.EctoAdapterLoadTest do
use ExUnit.Case, async: false
alias Acx.EnforcerServer
alias Acx.Enforcer
alias Acx.Persist.EctoAdapter

defmodule MockAclRepo do
use Acx.Persist.MockRepo, pfile: "../data/acl.csv" |> Path.expand(__DIR__)
end

defmodule MockRbacRepo do
use Acx.Persist.MockRepo, pfile: "../data/rbac.csv" |> Path.expand(__DIR__)
end

@acl_cfile "../data/acl.conf" |> Path.expand(__DIR__)
@rbac_cfile "../data/rbac.conf" |> Path.expand(__DIR__)

describe "load_policies_from_adapter/1 for ACL model" do
setup do
# Start a fresh enforcer for each test
enforcer_name = :"test_enforcer_#{:erlang.unique_integer([:positive])}"
{:ok, _pid} = EnforcerServer.start_link(enforcer_name, @acl_cfile)

on_exit(fn ->
case Process.whereis({:via, Registry, {Acx.EnforcerRegistry, enforcer_name}}) do
nil -> :ok
pid -> Process.exit(pid, :kill)
end
end)

{:ok, enforcer_name: enforcer_name}
end

test "loads policies from adapter into memory", %{enforcer_name: enforcer_name} do

Check failure on line 34 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test load_policies_from_adapter/1 for ACL model loads policies from adapter into memory (Acx.Persist.EctoAdapterLoadTest)

Check failure on line 34 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test load_policies_from_adapter/1 for ACL model loads policies from adapter into memory (Acx.Persist.EctoAdapterLoadTest)
# Set the persist adapter
adapter = EctoAdapter.new(MockAclRepo)
:ok = EnforcerServer.set_persist_adapter(enforcer_name, adapter)

# Load policies from the adapter
:ok = EnforcerServer.load_policies_from_adapter(enforcer_name)

# Verify that policies are loaded into memory by checking permissions
assert EnforcerServer.allow?(enforcer_name, ["alice", "blog_post", "read"]) === true
assert EnforcerServer.allow?(enforcer_name, ["alice", "blog_post", "create"]) === true
assert EnforcerServer.allow?(enforcer_name, ["bob", "blog_post", "read"]) === true
assert EnforcerServer.allow?(enforcer_name, ["bob", "blog_post", "create"]) === false
assert EnforcerServer.allow?(enforcer_name, ["peter", "blog_post", "modify"]) === true
end

test "returns error when adapter is not set", %{enforcer_name: enforcer_name} do

Check failure on line 50 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test load_policies_from_adapter/1 for ACL model returns error when adapter is not set (Acx.Persist.EctoAdapterLoadTest)

Check failure on line 50 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test load_policies_from_adapter/1 for ACL model returns error when adapter is not set (Acx.Persist.EctoAdapterLoadTest)
# Try to load without setting an adapter
result = EnforcerServer.load_policies_from_adapter(enforcer_name)
assert result === {:error, "No adapter set and no policy file provided"}
end

test "policies are available after restart simulation", %{enforcer_name: enforcer_name} do

Check failure on line 56 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test load_policies_from_adapter/1 for ACL model policies are available after restart simulation (Acx.Persist.EctoAdapterLoadTest)

Check failure on line 56 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test load_policies_from_adapter/1 for ACL model policies are available after restart simulation (Acx.Persist.EctoAdapterLoadTest)
# Simulate the issue scenario: configure adapter, add policy, restart
adapter = EctoAdapter.new(MockAclRepo)
:ok = EnforcerServer.set_persist_adapter(enforcer_name, adapter)

# Load policies from adapter (simulating startup after restart)
:ok = EnforcerServer.load_policies_from_adapter(enforcer_name)

# Verify the loaded policies work
assert EnforcerServer.allow?(enforcer_name, ["alice", "blog_post", "delete"]) === true
end
end

describe "load_policies_from_adapter/1 for RBAC model" do
setup do
# Start a fresh enforcer for each test
enforcer_name = :"test_enforcer_rbac_#{:erlang.unique_integer([:positive])}"
{:ok, _pid} = EnforcerServer.start_link(enforcer_name, @rbac_cfile)

on_exit(fn ->
case Process.whereis({:via, Registry, {Acx.EnforcerRegistry, enforcer_name}}) do
nil -> :ok
pid -> Process.exit(pid, :kill)
end
end)

{:ok, enforcer_name: enforcer_name}
end

test "loads both policies and mapping policies from adapter", %{enforcer_name: enforcer_name} do

Check failure on line 85 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test load_policies_from_adapter/1 for RBAC model loads both policies and mapping policies from adapter (Acx.Persist.EctoAdapterLoadTest)

Check failure on line 85 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test load_policies_from_adapter/1 for RBAC model loads both policies and mapping policies from adapter (Acx.Persist.EctoAdapterLoadTest)
# Set the persist adapter
adapter = EctoAdapter.new(MockRbacRepo)
:ok = EnforcerServer.set_persist_adapter(enforcer_name, adapter)

# Load policies and mapping policies from the adapter
:ok = EnforcerServer.load_policies_from_adapter(enforcer_name)

# Verify that policies are loaded
assert EnforcerServer.allow?(enforcer_name, ["bob", "blog_post", "read"]) === true

# Verify that role mappings are loaded (bob has reader role, which has read permission)
# peter is author who inherits from reader
assert EnforcerServer.allow?(enforcer_name, ["peter", "blog_post", "read"]) === true
assert EnforcerServer.allow?(enforcer_name, ["peter", "blog_post", "create"]) === true

# alice is admin who inherits from author (and reader through author)
assert EnforcerServer.allow?(enforcer_name, ["alice", "blog_post", "read"]) === true
assert EnforcerServer.allow?(enforcer_name, ["alice", "blog_post", "create"]) === true
assert EnforcerServer.allow?(enforcer_name, ["alice", "blog_post", "delete"]) === true
end
end

describe "Enforcer.load_policies!/1 (direct usage)" do
test "loads policies from adapter directly on Enforcer struct" do
adapter = EctoAdapter.new(MockAclRepo)
{:ok, e} = Enforcer.init(@acl_cfile, adapter)

# Load policies using the adapter
e = Enforcer.load_policies!(e)

# Verify policies are loaded
assert Enforcer.allow?(e, ["alice", "blog_post", "read"]) === true
assert Enforcer.allow?(e, ["bob", "blog_post", "create"]) === false
end

test "returns error when no adapter is set" do

Check failure on line 121 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test Enforcer.load_policies!/1 (direct usage) returns error when no adapter is set (Acx.Persist.EctoAdapterLoadTest)

Check failure on line 121 in test/persist/ecto_adapter_load_test.exs

View workflow job for this annotation

GitHub Actions / Test (1.14.2, 25.1.1)

test Enforcer.load_policies!/1 (direct usage) returns error when no adapter is set (Acx.Persist.EctoAdapterLoadTest)
{:ok, e} = Enforcer.init(@acl_cfile)

# Try to load without an adapter
result = Enforcer.load_policies!(e)
assert result === {:error, "No adapter set and no policy file provided"}
end
end
end
Loading
Loading