Skip to content
34 changes: 34 additions & 0 deletions lib/acx/enforcer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,40 @@ defmodule Acx.Enforcer do
end
end

@doc """
Loads policies from the persist adapter using the given filter.
Only matching policies are loaded into the enforcer.

The filter is a map where keys can be `:ptype`, `:v0`, `:v1`, `:v2`, `:v3`, `:v4`, `:v5`, or `:v6`.
Values can be either a single string or a list of strings for matching multiple values.

## Examples

# Load only policies for a specific domain
enforcer = Enforcer.load_filtered_policies!(enforcer, %{v3: "org:abc"})

# Load policies with multiple criteria
enforcer = Enforcer.load_filtered_policies!(enforcer, %{ptype: "p", v3: ["org:tenant_1", "org:tenant_2"]})
"""
@spec load_filtered_policies!(t(), map()) :: t()
def load_filtered_policies!(
%__MODULE__{model: m, persist_adapter: adapter} = enforcer,
filter
)
when is_map(filter) do
case PersistAdapter.load_filtered_policy(adapter, filter) do
{:ok, policies} ->
policies
|> Enum.map(fn [key | attrs] -> [String.to_atom(key) | attrs] end)
|> Enum.filter(fn [key | _] -> Model.has_policy_key?(m, key) end)
|> Enum.map(fn [key | attrs] -> {key, attrs} end)
|> Enum.reduce(enforcer, &load_policy!(&2, &1))

{:error, reason} ->
raise ArgumentError, message: reason
end
end

@doc """
Returns a list of policies in the given enforcer that match the
given criteria.
Expand Down
16 changes: 16 additions & 0 deletions lib/acx/enforcer_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ defmodule Acx.EnforcerServer do
GenServer.call(via_tuple(ename), {:load_policies, pfile})
end

@doc """
Loads filtered policies from the persist adapter.
Only policies matching the filter are loaded into the enforcer.

See `Enforcer.load_filtered_policies!/2` for more details.
"""
def load_filtered_policies(ename, filter) do
GenServer.call(via_tuple(ename), {:load_filtered_policies, filter})
end

@doc """
Returns a list of policies in the given enforcer that match the
given criteria.
Expand Down Expand Up @@ -245,6 +255,12 @@ defmodule Acx.EnforcerServer do
{:reply, :ok, new_enforcer}
end

def handle_call({:load_filtered_policies, filter}, _from, enforcer) do
new_enforcer = enforcer |> Enforcer.load_filtered_policies!(filter)
: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
76 changes: 76 additions & 0 deletions lib/acx/persist/ecto_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,82 @@ defmodule Acx.Persist.EctoAdapter do
{:ok, policies}
end

@doc """
Loads only policies matching the given filter from the database.

The filter is a map where keys can be `:ptype`, `:v0`, `:v1`, `:v2`, `:v3`, `:v4`, `:v5`, or `:v6`.
Values can be either a single string or a list of strings for matching multiple values.

## Examples

# Load policies for a specific domain
filter = %{v3: "org:tenant_123"}
PersistAdapter.load_filtered_policy(adapter, filter)

# Load policies with multiple criteria
filter = %{ptype: "p", v3: ["org:tenant_1", "org:tenant_2"]}
PersistAdapter.load_filtered_policy(adapter, filter)

iex> PersistAdapter.load_filtered_policy(%Acx.Persist.EctoAdapter{repo: nil}, %{})
...> {:error, "repo is not set"}
"""
@spec load_filtered_policy(EctoAdapter.t(), map()) :: {:ok, [list()]} | {:error, String.t()}
def load_filtered_policy(%Acx.Persist.EctoAdapter{repo: nil}, _filter) do
{:error, "repo is not set"}
end

def load_filtered_policy(adapter, filter) when is_map(filter) do
query = build_filtered_query(filter)

policies =
adapter.repo.all(query)
|> Enum.map(&CasbinRule.changeset_to_list(&1))

{:ok, policies}
end

defp build_filtered_query(filter) do
import Ecto.Query
base_query = from(r in CasbinRule)

Enum.reduce(filter, base_query, fn {field, value}, query ->
add_where_clause(query, field, value)
end)
end

# Helper function to add WHERE clause for a single filter condition
defp add_where_clause(query, field, values) when is_list(values) do
import Ecto.Query

case field do
:ptype -> where(query, [r], r.ptype in ^values)
:v0 -> where(query, [r], r.v0 in ^values)
:v1 -> where(query, [r], r.v1 in ^values)
:v2 -> where(query, [r], r.v2 in ^values)
:v3 -> where(query, [r], r.v3 in ^values)
:v4 -> where(query, [r], r.v4 in ^values)
:v5 -> where(query, [r], r.v5 in ^values)
:v6 -> where(query, [r], r.v6 in ^values)
_ -> query
end
end

defp add_where_clause(query, field, value) do
import Ecto.Query

case field do
:ptype -> where(query, [r], r.ptype == ^value)
:v0 -> where(query, [r], r.v0 == ^value)
:v1 -> where(query, [r], r.v1 == ^value)
:v2 -> where(query, [r], r.v2 == ^value)
:v3 -> where(query, [r], r.v3 == ^value)
:v4 -> where(query, [r], r.v4 == ^value)
:v5 -> where(query, [r], r.v5 == ^value)
:v6 -> where(query, [r], r.v6 == ^value)
_ -> query
end
end

@doc """
Uses the configured repo to insert a Policy into the casbin_rule table.

Expand Down
1 change: 1 addition & 0 deletions lib/acx/persist/persist_adapter.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defprotocol Acx.Persist.PersistAdapter do
def load_policies(adapter)
def load_filtered_policy(adapter, filter)
def add_policy(adapter, policy)
def remove_policy(adapter, policy)
def remove_filtered_policy(adapter, key, idx, attrs)
Expand Down
70 changes: 70 additions & 0 deletions lib/acx/persist/readonly_file_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,76 @@ defmodule Acx.Persist.ReadonlyFileAdapter do
{:ok, policies}
end

@doc """
Loads filtered policies from a file.

The filter is applied in-memory after loading all policies.
Note: For file-based adapters, filtering does not improve performance
as all data must be read from disk anyway.

## Examples

filter = %{ptype: "p", v3: "org:tenant_123"}
PersistAdapter.load_filtered_policy(adapter, filter)
"""
def load_filtered_policy(%Acx.Persist.ReadonlyFileAdapter{policy_file: nil}, _filter) do
{:ok, []}
end

def load_filtered_policy(adapter, filter) when is_map(filter) do
case load_policies(adapter) do
{:ok, policies} ->
filtered_policies = apply_filter(policies, filter)
{:ok, filtered_policies}

error ->
error
end
end

defp apply_filter(policies, filter) do
Enum.filter(policies, fn policy ->
matches_filter?(policy, filter)
end)
end

defp matches_filter?(policy, filter) do
Enum.all?(filter, fn {key, value} ->
policy_value = get_policy_value(policy, key)
matches_value?(policy_value, value)
end)
end

defp get_policy_value([ptype | _values], :ptype), do: ptype

defp get_policy_value([_ptype | values], key) do
index =
case key do
:v0 -> 0
:v1 -> 1
:v2 -> 2
:v3 -> 3
:v4 -> 4
:v5 -> 5
:v6 -> 6
_ -> nil
end

if index && index < length(values) do
Enum.at(values, index)
else
nil
end
end

defp matches_value?(policy_value, filter_value) when is_list(filter_value) do
policy_value in filter_value
end

defp matches_value?(policy_value, filter_value) do
policy_value == filter_value
end

def add_policy(adapter, _policy) do
{:ok, adapter}
end
Expand Down
26 changes: 26 additions & 0 deletions test/persist/filtered_policy_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
defmodule Acx.Persist.FilteredPolicyTest do
use ExUnit.Case, async: true
alias Acx.Enforcer
alias Acx.Persist.EctoAdapter
alias Acx.Persist.PersistAdapter

@cfile "../data/rbac_domain.conf" |> Path.expand(__DIR__)

describe "load_filtered_policy/2 with EctoAdapter" do
test "returns error when repo is not set" do
adapter = EctoAdapter.new(nil)
assert {:error, "repo is not set"} == PersistAdapter.load_filtered_policy(adapter, %{})
end
end

describe "load_filtered_policies!/2 with Enforcer" do
test "raises error when repo is not set" do
adapter = EctoAdapter.new(nil)
{:ok, e} = Enforcer.init(@cfile, adapter)

assert_raise ArgumentError, "repo is not set", fn ->
Enforcer.load_filtered_policies!(e, %{v2: "domain1"})
end
end
end
end
113 changes: 113 additions & 0 deletions test/persist/readonly_file_adapter_filtered_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
defmodule Acx.Persist.ReadonlyFileAdapterFilteredTest do
use ExUnit.Case, async: true
alias Acx.Enforcer
alias Acx.Persist.ReadonlyFileAdapter
alias Acx.Persist.PersistAdapter

@cfile "../data/rbac_domain.conf" |> Path.expand(__DIR__)
@pfile "../data/rbac_domain.csv" |> Path.expand(__DIR__)

describe "load_filtered_policy/2 with ReadonlyFileAdapter" do
test "filters policies by domain" do
adapter = ReadonlyFileAdapter.new(@pfile)

# Load only policies for domain1 (domain is at v1 position)
{:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{v1: "domain1"})

# Should only have policies with domain1
assert length(policies) == 2
assert Enum.all?(policies, fn [_ptype, _subj, domain, _obj, _act] -> domain == "domain1" end)
end

test "filters policies by ptype" do
adapter = ReadonlyFileAdapter.new(@pfile)

# Load only p (policy) rules, not g (role) rules
{:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{ptype: "p"})

# Should only have p rules
assert length(policies) == 5
assert Enum.all?(policies, fn [ptype | _] -> ptype == "p" end)
end

test "filters policies by multiple criteria" do
adapter = ReadonlyFileAdapter.new(@pfile)

# Load only p rules for domain2 (domain is at v1 position)
{:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{ptype: "p", v1: "domain2"})

# Should only have p rules with domain2
assert length(policies) == 2
assert Enum.all?(policies, fn [ptype, _subj, domain, _obj, _act] ->
ptype == "p" && domain == "domain2"
end)
end

test "filters policies by list of values" do
adapter = ReadonlyFileAdapter.new(@pfile)

# Load policies for domain1 OR domain2 (domain is at v1 position)
{:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{v1: ["domain1", "domain2"]})

# Should have policies with domain1 or domain2
assert length(policies) == 4
assert Enum.all?(policies, fn [_ptype, _subj, domain, _obj, _act] ->
domain in ["domain1", "domain2"]
end)
end

test "returns empty list when policy file is nil" do
adapter = ReadonlyFileAdapter.new()
assert {:ok, []} == PersistAdapter.load_filtered_policy(adapter, %{})
end

test "returns empty list when no policies match filter" do
adapter = ReadonlyFileAdapter.new(@pfile)

# Load policies for non-existent domain
{:ok, policies} = PersistAdapter.load_filtered_policy(adapter, %{v1: "non_existent_domain"})

assert policies == []
end
end

describe "load_filtered_policies!/2 with Enforcer and ReadonlyFileAdapter" do
test "loads only filtered policies into enforcer" do
{:ok, e} = Enforcer.init(@cfile)
adapter = ReadonlyFileAdapter.new(@pfile)
e = Enforcer.set_persist_adapter(e, adapter)

# Load only policies for domain1 (domain is at v1 position)
e = Enforcer.load_filtered_policies!(e, %{v1: "domain1"})
|> Enforcer.load_mapping_policies!()

# Should only have domain1 policies
policies = Enforcer.list_policies(e)
assert length(policies) == 2

# Domain1 requests should work
assert Enforcer.allow?(e, ["alice", "domain1", "data1", "read"]) === true
assert Enforcer.allow?(e, ["alice", "domain1", "data1", "write"]) === true

# Domain2 requests should not work (policies not loaded)
assert Enforcer.allow?(e, ["alice", "domain2", "data2", "read"]) === false
assert Enforcer.allow?(e, ["bob", "domain2", "data2", "read"]) === false
end

test "loads only p policies when filtered by ptype" do
{:ok, e} = Enforcer.init(@cfile)
adapter = ReadonlyFileAdapter.new(@pfile)
e = Enforcer.set_persist_adapter(e, adapter)

# Load only p rules, not g rules
e = Enforcer.load_filtered_policies!(e, %{ptype: "p"})

policies = Enforcer.list_policies(e)
assert length(policies) == 5

# Without role mappings loaded, role-based permissions should not work
mapping_policies = Enforcer.list_mapping_policies(e)
assert length(mapping_policies) == 0
end
end
end
Loading