Skip to content
Merged
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
110 changes: 110 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,116 @@ end
As you can see, the cost of switching or upgrading to another access control
mechanism is as simple as modifying the configuration.

## Persisting Policies to Database with EctoAdapter

Instead of loading policies from CSV files, you can persist them to a database using the `EctoAdapter`. This provides a more dynamic and scalable approach, especially for production environments where policies may change frequently.

### Setup

First, run the migration to create the `casbin_rule` table:

```elixir
# In your migration file
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
end
end
```

### Loading Policies from Database

```elixir
alias Casbin.{EnforcerSupervisor, EnforcerServer}
alias Casbin.Persist.EctoAdapter

ename = "blog_ac"

# Start the enforcer
EnforcerSupervisor.start_enforcer(ename, "blog_ac.conf")

# Configure the database adapter
adapter = EctoAdapter.new(MyApp.Repo)
EnforcerServer.set_persist_adapter(ename, adapter)

# Load policies from database
EnforcerServer.load_policies(ename)

# For RBAC models, also load mapping policies
EnforcerServer.load_mapping_policies(ename)

# Now you can check permissions
case EnforcerServer.allow?(ename, ["alice", "blog_post", "read"]) do
true -> # Access granted
false -> # Access denied
end
```

### Auto-Persist on Policy Changes

Once you set the `EctoAdapter`, any policy modifications are automatically persisted:

```elixir
# This policy is automatically saved to the database
EnforcerServer.add_policy(ename, {:p, ["bob", "data", "write"]})

# This mapping is also auto-saved
EnforcerServer.add_mapping_policy(ename, {:g, "carol", "admin"})
```

### Application Startup Pattern

For production applications, you typically want to load policies from the database on startup:

```elixir
defmodule MyApp.Application do
use Application

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

opts = [strategy: :one_for_one, name: MyApp.Supervisor]
{:ok, pid} = Supervisor.start_link(children, opts)

# Initialize enforcer with policies from database
setup_casbin()

{:ok, pid}
end

defp setup_casbin do
ename = "my_enforcer"
model_path = Application.app_dir(:my_app, "priv/casbin/model.conf")

# Start enforcer
Casbin.EnforcerSupervisor.start_enforcer(ename, model_path)

# Configure database adapter
adapter = Casbin.Persist.EctoAdapter.new(MyApp.Repo)
Casbin.EnforcerServer.set_persist_adapter(ename, adapter)

# Load policies from database
Casbin.EnforcerServer.load_policies(ename)
Casbin.EnforcerServer.load_mapping_policies(ename)
end
end
```

## RESTful Example

The config file:
Expand Down
52 changes: 52 additions & 0 deletions lib/casbin/enforcer_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ defmodule Casbin.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.

The persist adapter must be set using `set_persist_adapter/2` before
calling this function, otherwise an error will be returned.

## Examples

# Set an EctoAdapter and load policies from database
adapter = EctoAdapter.new(MyApp.Repo)
EnforcerServer.set_persist_adapter("my_enforcer", adapter)
EnforcerServer.load_policies("my_enforcer")

See `Enforcer.load_policies!/1` for more details.
"""
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.
Expand Down Expand Up @@ -164,6 +184,26 @@ defmodule Casbin.EnforcerServer do
)
end

@doc """
Loads mapping policies from the configured persist adapter and adds them
to the enforcer.

The persist adapter must be set using `set_persist_adapter/2` before
calling this function, otherwise an error will be returned.

## Examples

# Set an EctoAdapter and load mapping policies from database
adapter = EctoAdapter.new(MyApp.Repo)
EnforcerServer.set_persist_adapter("my_enforcer", adapter)
EnforcerServer.load_mapping_policies("my_enforcer")

See `Enforcer.load_mapping_policies!/1` for more details.
"""
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.

Expand Down Expand Up @@ -249,6 +289,12 @@ defmodule Casbin.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})
Expand Down Expand Up @@ -286,6 +332,12 @@ defmodule Casbin.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})
Expand Down
153 changes: 153 additions & 0 deletions test/persist/ecto_server_load_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
defmodule Casbin.Persist.EctoServerLoadTest do
use ExUnit.Case, async: true
alias Casbin.{EnforcerServer, EnforcerSupervisor}
alias Casbin.Persist.EctoAdapter

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

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

@cfile_acl "../data/acl.conf" |> Path.expand(__DIR__)
@cfile_rbac "../data/rbac.conf" |> Path.expand(__DIR__)
@repo_acl MockAclRepo
@repo_rbac MockRbacRepo

setup do
# Ensure clean state
:ets.delete_all_objects(:enforcers_table)
:ok
end

describe "load_policies/1 with ACL model" do
test "loads policies from EctoAdapter on startup" do
ename = "test_acl_load_#{:erlang.unique_integer([:positive])}"

# Start enforcer
{:ok, _pid} = EnforcerSupervisor.start_enforcer(ename, @cfile_acl)

# Set persist adapter
adapter = EctoAdapter.new(@repo_acl)
:ok = EnforcerServer.set_persist_adapter(ename, adapter)

# Load policies from adapter
:ok = EnforcerServer.load_policies(ename)

# Verify policies are loaded
assert EnforcerServer.allow?(ename, ["alice", "blog_post", "create"]) === true
assert EnforcerServer.allow?(ename, ["alice", "blog_post", "delete"]) === true
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", "create"]) === true
assert EnforcerServer.allow?(ename, ["peter", "blog_post", "delete"]) === false
end

test "returns error when no adapter is set" do
ename = "test_acl_no_adapter_#{:erlang.unique_integer([:positive])}"

# Start enforcer without setting adapter
{:ok, _pid} = EnforcerSupervisor.start_enforcer(ename, @cfile_acl)

# Try to load policies without adapter - should work with readonly file adapter
:ok = EnforcerServer.load_policies(ename)
end
end

describe "load_policies/1 and load_mapping_policies/1 with RBAC model" do
test "loads both policies and mapping policies from EctoAdapter" do
ename = "test_rbac_load_#{:erlang.unique_integer([:positive])}"

# Start enforcer
{:ok, _pid} = EnforcerSupervisor.start_enforcer(ename, @cfile_rbac)

# Set persist adapter
adapter = EctoAdapter.new(@repo_rbac)
:ok = EnforcerServer.set_persist_adapter(ename, adapter)

# Load policies and mapping policies from adapter
:ok = EnforcerServer.load_policies(ename)
:ok = EnforcerServer.load_mapping_policies(ename)

# Verify policies are loaded and role mappings work
# bob has role reader, reader can read
assert EnforcerServer.allow?(ename, ["bob", "blog_post", "read"]) === true
assert EnforcerServer.allow?(ename, ["bob", "blog_post", "create"]) === false

# peter has role author, author inherits reader and can also create/modify
assert EnforcerServer.allow?(ename, ["peter", "blog_post", "read"]) === true
assert EnforcerServer.allow?(ename, ["peter", "blog_post", "create"]) === true
assert EnforcerServer.allow?(ename, ["peter", "blog_post", "modify"]) === true
assert EnforcerServer.allow?(ename, ["peter", "blog_post", "delete"]) === false

# alice has role admin, admin inherits author which inherits reader
assert EnforcerServer.allow?(ename, ["alice", "blog_post", "read"]) === true
assert EnforcerServer.allow?(ename, ["alice", "blog_post", "create"]) === true
assert EnforcerServer.allow?(ename, ["alice", "blog_post", "modify"]) === true
assert EnforcerServer.allow?(ename, ["alice", "blog_post", "delete"]) === true
end
end

describe "backward compatibility" do
test "load_policies/2 with file path still works" do
ename = "test_file_load_#{:erlang.unique_integer([:positive])}"
pfile = "../data/acl.csv" |> Path.expand(__DIR__)

# Start enforcer
{:ok, _pid} = EnforcerSupervisor.start_enforcer(ename, @cfile_acl)

# Load policies from file
:ok = EnforcerServer.load_policies(ename, pfile)

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

test "load_mapping_policies/2 with file path still works" do
ename = "test_file_mapping_load_#{:erlang.unique_integer([:positive])}"
pfile = "../data/rbac.csv" |> Path.expand(__DIR__)

# Start enforcer
{:ok, _pid} = EnforcerSupervisor.start_enforcer(ename, @cfile_rbac)

# Load policies and mapping policies from file
:ok = EnforcerServer.load_policies(ename, pfile)
:ok = EnforcerServer.load_mapping_policies(ename, pfile)

# Verify role mappings work
assert EnforcerServer.allow?(ename, ["alice", "blog_post", "delete"]) === true
end
end

describe "persistence workflow" do
test "complete workflow: set adapter, load, modify, verify" do
ename = "test_workflow_#{:erlang.unique_integer([:positive])}"

# Start enforcer
{:ok, _pid} = EnforcerSupervisor.start_enforcer(ename, @cfile_acl)

# Set persist adapter
adapter = EctoAdapter.new(@repo_acl)
:ok = EnforcerServer.set_persist_adapter(ename, adapter)

# Load policies from adapter
:ok = EnforcerServer.load_policies(ename)

# Verify initial state
assert EnforcerServer.allow?(ename, ["alice", "blog_post", "create"]) === true

# Add a new policy (should persist to adapter)
:ok = EnforcerServer.add_policy(ename, {:p, ["alice", "data", "read"]})

# Verify new policy works
assert EnforcerServer.allow?(ename, ["alice", "data", "read"]) === true

# List policies to verify it's there
policies = EnforcerServer.list_policies(ename, %{sub: "alice", obj: "data"})
assert length(policies) === 1
end
end
end
10 changes: 10 additions & 0 deletions test/support/mock_repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ defmodule Casbin.Persist.MockRepo do
{1, nil}
end

def transaction(fun) when is_function(fun, 0) do
{:ok, fun.()}
end

def transaction(fun) when is_function(fun, 1) do
# For transaction with repo argument
result = fun.(__MODULE__)
{:ok, result}
end

# Allow override in using modules
defoverridable all: 1, all: 2
end
Expand Down
Loading