Skip to content

Add EnforcerServer.load_policies_from_adapter/1 for database policy loading - #36

Closed
hsluoyz with Copilot wants to merge 5 commits into
masterfrom
copilot/add-auto-load-policies-feature
Closed

Add EnforcerServer.load_policies_from_adapter/1 for database policy loading#36
hsluoyz with Copilot wants to merge 5 commits into
masterfrom
copilot/add-auto-load-policies-feature

Conversation

Copilot AI commented Oct 30, 2025

Copy link
Copy Markdown
Contributor

Summary

Implements LoadPolicy API for EctoAdapter to address issue: "EctoAdapter: No Built-in Way to Load Policies from Database on Startup"

Changes

  • Add load_policies_from_adapter/1 function to EnforcerServer
  • Add corresponding handle_call for :load_policies_from_adapter in EnforcerServer
  • Leverage existing Enforcer.load_policies!/1 and Enforcer.load_mapping_policies!/1 functions
  • Add comprehensive tests for the new functionality including error handling
  • Add documentation to README explaining how to use the new feature
  • Fix test failures (race conditions, cleanup issues, assertion mismatches)

API Usage

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

# Load policies from database (NEW!)
EnforcerServer.load_policies_from_adapter("my_enforcer")

# Policies are now loaded into memory
EnforcerServer.allow?("my_enforcer", ["admin", "data", "write"])

Implementation Notes

  • Uses existing LoadPolicy API via PersistAdapter.load_policies/1 as suggested by maintainer
  • Follows the same pattern as Golang Casbin adapters
  • Loads both regular policies and mapping policies (RBAC roles)
  • Minimal changes (22 lines to enforcer_server.ex)
  • Backward compatible, no breaking changes
Original prompt

This section details on the original issue you should resolve

<issue_title>EctoAdapter: No Built-in Way to Load Policies from Database on Startup</issue_title>
<issue_description>## Summary
The EctoAdapter automatically saves policies to the database but provides no clean way to load them back into the enforcer's memory on application startup. This creates an asymmetric API and forces developers to implement manual workarounds.

Current Behavior

What Works (Auto-Save)

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

# Add policy - automatically saved to DB
EnforcerServer.add_policy("my_enforcer", {:p, ["admin", "data", "write", "org:abc"]})
# ✅ Policy is now in both memory AND database

What Doesn't Work (No Auto-Load)

# Application restart...

# Re-configure adapter
adapter = EctoAdapter.new(Repo)
EnforcerServer.set_persist_adapter("my_enforcer", adapter)

# ❌ Policies from database are NOT loaded into memory
EnforcerServer.allow?("my_enforcer", ["admin", "data", "write", "org:abc"])
# => false (policies exist in DB but not in memory)

Expected API

There should be a clean, built-in way to load policies from the database:
# Option 1: Automatic on adapter setup
adapter = EctoAdapter.new(Repo)
EnforcerServer.set_persist_adapter("my_enforcer", adapter, load: true)

# Option 2: Explicit load function
EnforcerServer.load_policies_from_adapter("my_enforcer")

# Option 3: Enhanced load_policies that works with adapters
EnforcerServer.load_policies("my_enforcer", :from_adapter)

Current Workaround (Manual Implementation)

Developers must implement their own loading logic by querying the database directly and manually adding each policy:

defp load_policies_from_db do
  # Manually query the database
  rules = Repo.all(Acx.Persist.EctoAdapter.CasbinRule)

  # Manually add each rule to the enforcer's memory
  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

Why This is Problematic

  1. The set_persist_adapter/2 function suggests persistence is fully configured, but it only handles saves, not loads.
  2. New users expect load_policies/2 to work with adapters, but it only accepts file paths:
# This signature is misleading:
EnforcerServer.load_policies(name, file)
# "file" parameter suggests it ONLY loads from files
  1. Comparison with PersistAdapter.load_policies/1
The library DOES have a function that retrieves policies from the adapter:
{:ok, {policies, grouping_policies}} = Acx.Persist.PersistAdapter.load_policies(adapter)

But: There's no function to feed these policies back into the enforcer's memory. Developers must manually loop through and add each one.

Proposed Solutions

Option 1: Add load_policies_from_adapter/1
EnforcerServer.load_policies_from_adapter("my_enforcer")

Option 2: Enhance set_persist_adapter/3 with options
EnforcerServer.set_persist_adapter("my_enforcer", adapter, auto_load: true)

Option 3: Make load_policies/2 adapter-aware
Accept atom :adapter as second parameter
EnforcerServer.load_policies("my_enforcer", :adapter)

Option 4: Add Enforcer.load_policies_from_adapter/2
enforcer = Enforcer.init(model_path)
enforcer = Enforcer.load_policies_from_adapter(enforcer, adapter)</issue_description>

Comments on the Issue (you are @copilot in this section)

@hsluoyz Casbin adapters use LoadPolicy API to load policy from DB

Why not use LoadPolicy? If LoadPolicy is not supported, need to support it

Refer to Golang's adapters:


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@hsluoyz
hsluoyz marked this pull request as ready for review October 30, 2025 13:43
Copilot AI and others added 3 commits October 30, 2025 13:57
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>
Copilot AI changed the title [WIP] Add auto-load feature for policies in EctoAdapter Add EnforcerServer.load_policies_from_adapter/1 for database policy loading Oct 30, 2025
Copilot AI requested a review from hsluoyz October 30, 2025 14:04
@hsluoyz

hsluoyz commented Oct 30, 2025

Copy link
Copy Markdown
Member

@copilot fix failed CI check:

Run mix test
  mix test
  shell: /usr/bin/bash -e {0}
  env:
    INSTALL_DIR_FOR_OTP: /home/runner/work/_temp/.setup-beam/otp
    INSTALL_DIR_FOR_ELIXIR: /home/runner/work/_temp/.setup-beam/elixir
Compiling 1 file (.ex)
Generated acx app
.............................................................................................................................................................................................................................................................................................................................................................................................
14:16:24.267 [info] Spawned an enforcer process named 'test_load_from_adapter'

14:16:24.282 [info] Spawned an enforcer process named 'test_load_from_adapter'

14:16:24.286 [info] Spawned an enforcer process named 'test_load_from_adapter'

14:16:24.286 [info] Spawned an enforcer process named 'test_load_from_adapter'


  1) test Enforcer.load_policies!/1 with adapter loads policies from configured adapter (Acx.Persist.LoadPoliciesFromAdapterTest)
Error:      test/persist/load_policies_from_adapter_test.exs:105
     ** (ArgumentError) errors were found at the given arguments:
     
       * 1st argument: not an atom
     
     stacktrace:
       :erlang.whereis({:via, Registry, {Acx.EnforcerRegistry, :test_load_from_adapter}})
       (elixir 1.14.2) lib/process.ex:653: Process.whereis/1
       test/persist/load_policies_from_adapter_test.exs:26: anonymous fn/0 in Acx.Persist.LoadPoliciesFromAdapterTest.__ex_unit_setup_0/1
       (ex_unit 1.14.2) lib/ex_unit/on_exit_handler.ex:143: ExUnit.OnExitHandler.exec_callback/1
       (ex_unit 1.14.2) lib/ex_unit/on_exit_handler.ex:129: ExUnit.OnExitHandler.on_exit_runner_loop/0


14:16:24.289 [info] Spawned an enforcer process named 'test_load_from_adapter'


  2) test EnforcerServer.load_policies_from_adapter/1 lists loaded policies after loading from adapter (Acx.Persist.LoadPoliciesFromAdapterTest)
Error:      test/persist/load_policies_from_adapter_test.exs:52
     Assertion with != failed, both sides are exactly equal
     code: assert alice_delete_policy != nil
     left: nil
     stacktrace:
       test/persist/load_policies_from_adapter_test.exs:68: (test)


14:16:24.289 [info] Spawned an enforcer process named 'test_load_from_adapter'


  3) test Enforcer.load_policies!/1 with adapter returns error when no adapter is set (Acx.Persist.LoadPoliciesFromAdapterTest)
Error:      test/persist/load_policies_from_adapter_test.exs:97
     Assertion with == failed
     code:  assert result == {:error, "No adapter set and no policy file provided"}
     left:  %Acx.Enforcer{
              model: %Acx.Model{
                request: %Acx.Model.RequestDefinition{
                  key: :r,
                  attrs: [:sub, :obj, :act]
                },
                policies: [
                  %Acx.Model.PolicyDefinition{
                    key: :p,
                    attrs: [:sub, :obj, :act, :eft]
                  }
                ],
                matcher: %Acx.Model.Matcher{
                  prog: [
                    {:fetch_attr, %{attr: :sub, key: :r}},
                    {:fetch_attr, %{attr: :sub, key: :p}},
                    {:call, %{arity: 2, name: :g}},
                    {:fetch_attr, %{attr: :obj, key: :r}},
                    {:fetch_attr, %{attr: :obj, key: :p}},
                    {:eq},
                    {:and},
                    {:fetch_attr, %{attr: :act, key: :r}},
                    {:fetch_attr, %{attr: :act, key: :p}},
                    {:eq},
                    {:and}
                  ]
                },
                effect: %Acx.Model.PolicyEffect{
                  rule: "some(where(p.eft==allow))"
                },
                role_mappings: [:g]
              },
              policies: [],
              mapping_policies: [],
              role_groups: %{
                g: %Acx.Internal.RoleGroup{
                  name: :g,
                  role_graph: %Acx.Internal.Digraph{
                    vertices: %{},
                    adj: %{}
                  }
                }
              },
              env: %{
                g: #Function<1.76451738/2 in Acx.Internal.RoleGroup.stub_2/1>,
                keyMatch2: #Function<4.19422042/2 in Acx.Enforcer.key_match2?>,
                regexMatch: #Function<3.19422042/2 in Acx.Enforcer.regex_match?>
              },
              persist_adapter: %Acx.Persist.ReadonlyFileAdapter{
                policy_file: nil
              }
            }
     right: {:error, "No adapter set and no policy file provided"}
     stacktrace:
       test/persist/load_policies_from_adapter_test.exs:102: (test)



  4) test Enforcer.load_policies!/1 with adapter loads both policies and mapping policies (Acx.Persist.LoadPoliciesFromAdapterTest)
Error:      test/persist/load_policies_from_adapter_test.exs:119
     ** (ArgumentError) errors were found at the given arguments:
     
       * 1st argument: not an atom
     
     stacktrace:
       :erlang.whereis({:via, Registry, {Acx.EnforcerRegistry, :test_load_from_adapter}})
       (elixir 1.14.2) lib/process.ex:653: Process.whereis/1
       test/persist/load_policies_from_adapter_test.exs:26: anonymous fn/0 in Acx.Persist.LoadPoliciesFromAdapterTest.__ex_unit_setup_0/1
       (ex_unit 1.14.2) lib/ex_unit/on_exit_handler.ex:143: ExUnit.OnExitHandler.exec_callback/1
       (ex_unit 1.14.2) lib/ex_unit/on_exit_handler.ex:129: ExUnit.OnExitHandler.on_exit_runner_loop/0



  5) test EnforcerServer.load_policies_from_adapter/1 loads policies and mapping policies from the adapter (Acx.Persist.LoadPoliciesFromAdapterTest)
Error:      test/persist/load_policies_from_adapter_test.exs:36
     Expected false or nil, got true
     code: refute EnforcerServer.allow?(@enforcer_name, ["alice", "blog_post", "delete"])
     arguments:

         # 1
         :test_load_from_adapter

         # 2
         ["alice", "blog_post", "delete"]

     stacktrace:
       test/persist/load_policies_from_adapter_test.exs:38: (test)


Error: 14:16:24.296 [error] GenServer {Acx.EnforcerRegistry, :test_load_from_adapter} terminating
** (ArgumentError) got :already_existed while retrieving Exception.message/1 for %ArgumentError{message: :already_existed} (expected a string)
    (acx 1.3.0) lib/acx/enforcer.ex:143: Acx.Enforcer.load_policy!/2
    (elixir 1.14.2) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
    (acx 1.3.0) lib/acx/enforcer_server.ex:263: Acx.EnforcerServer.handle_call/3
    (stdlib 4.1.1) gen_server.erl:1149: :gen_server.try_handle_call/4
    (stdlib 4.1.1) gen_server.erl:1178: :gen_server.handle_msg/6
    (stdlib 4.1.1) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
Last message (from #PID<0.871.0>): {:load_policies_from_adapter}
State: %Acx.Enforcer{model: %Acx.Model{request: %Acx.Model.RequestDefinition{key: :r, attrs: [:sub, :obj, :act]}, policies: [%Acx.Model.PolicyDefinition{key: :p, attrs: [:sub, :obj, :act, :eft]}], matcher: %Acx.Model.Matcher{prog: [{:fetch_attr, %{attr: :sub, key: :r}}, {:fetch_attr, %{attr: :sub, key: :p}}, {:call, %{arity: 2, name: :g}}, {:fetch_attr, %{attr: :obj, key: :r}}, {:fetch_attr, %{attr: :obj, key: :p}}, {:eq}, {:and}, {:fetch_attr, %{attr: :act, key: :r}}, {:fetch_attr, %{attr: :act, key: :p}}, {:eq}, {:and}]}, effect: %Acx.Model.PolicyEffect{rule: "some(where(p.eft==allow))"}, role_mappings: [:g]}, policies: [%Acx.Model.Policy{key: :p, attrs: [sub: "admin", obj: "blog_post", act: "delete", eft: "allow"]}, %Acx.Model.Policy{key: :p, attrs: [sub: "author", obj: "blog_post", act: "create", eft: "allow"]}, %Acx.Model.Policy{key: :p, attrs: [sub: "author", obj: "blog_post", act: "modify", eft: "allow"]}, %Acx.Model.Policy{key: :p, attrs: [sub: "reader", obj: "blog_post", act: "read", eft: "allow"]}], mapping_policies: [{:g, "admin", "author"}, {:g, "author", "reader"}, {:g, "alice", "admin"}, {:g, "peter", "author"}, {:g, "bob", "reader"}], role_groups: %{g: %Acx.Internal.RoleGroup{name: :g, role_graph: %Acx.Internal.Digraph{vertices: %{336011412 => "admin", 501114122 => "reader", 987004049 => "alice", 1145658778 => "peter", 2065757877 => "author", 3265650504 => "bob"}, adj: %{336011412 => MapSet.new([2065757877]), 501114122 => MapSet.new([]), 987004049 => MapSet.new([336011412]), 1145658778 => MapSet.new([2065757877]), 2065757877 => MapSet.new([501114122]), 3265650504 => MapSet.new([501114122])}}}}, env: %{g: #Function<1.76451738/2 in Acx.Internal.RoleGroup.stub_2/1>, keyMatch2: #Function<4.19422042/2 in Acx.Enforcer.key_match2?>, regexMatch: #Function<3.19422042/2 in Acx.Enforcer.regex_match?>}, persist_adapter: %Acx.Persist.EctoAdapter{repo: Acx.Persist.LoadPoliciesFromAdapterTest.MockRepo}}
Client #PID<0.871.0> is alive

    (stdlib 4.1.1) gen.erl:256: :gen.do_call/4
    (elixir 1.14.2) lib/gen_server.ex:1035: GenServer.call/3
    test/persist/load_policies_from_adapter_test.exs:79: Acx.Persist.LoadPoliciesFromAdapterTest."test EnforcerServer.load_policies_from_adapter/1 works with enforcer lifecycle (startup simulation)"/1
    (ex_unit 1.14.2) lib/ex_unit/runner.ex:512: ExUnit.Runner.exec_test/1
    (stdlib 4.1.1) timer.erl:235: :timer.tc/1
    (ex_unit 1.14.2) lib/ex_unit/runner.ex:463: anonymous fn/4 in ExUnit.Runner.spawn_test_monitor/4


  6) test EnforcerServer.load_policies_from_adapter/1 works with enforcer lifecycle (startup simulation) (Acx.Persist.LoadPoliciesFromAdapterTest)
Error:      test/persist/load_policies_from_adapter_test.exs:71
     ** (EXIT from #PID<0.871.0>) an exception was raised:
         ** (ArgumentError) got :already_existed while retrieving Exception.message/1 for %ArgumentError{message: :already_existed} (expected a string)
             (acx 1.3.0) lib/acx/enforcer.ex:143: Acx.Enforcer.load_policy!/2
             (elixir 1.14.2) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
             (acx 1.3.0) lib/acx/enforcer_server.ex:263: Acx.EnforcerServer.handle_call/3
             (stdlib 4.1.1) gen_server.erl:1149: :gen_server.try_handle_call/4
             (stdlib 4.1.1) gen_server.erl:1178: :gen_server.handle_msg/6
             (stdlib 4.1.1) proc_lib.erl:240: :proc_lib.init_p_do_apply/3

..................................................................................................................................................................................
Finished in 0.9 seconds (0.9s async, 0.00s sync)
103 doctests, 462 tests, 6 failures

Randomized with seed 630791
Error: Process completed with exit code 2.

- Change async: true to async: false to prevent race conditions
- Use unique enforcer names per test to avoid conflicts
- Fix cleanup function to use GenServer.stop with proper via tuple
- Update error test to match actual behavior (returns empty enforcer, not error)
- Update policy assertions to match rbac.csv data (admin not alice)

Co-authored-by: hsluoyz <3787410+hsluoyz@users.noreply.github.com>

Copilot AI commented Oct 30, 2025

Copy link
Copy Markdown
Contributor Author

@copilot fix failed CI check:

...

Fixed the CI failures in commit b143870:

Issues resolved:

  1. Race conditions: Changed tests from async: true to async: false and used unique enforcer names per test
  2. Process cleanup error: Fixed Process.whereis to use GenServer.stop with proper via tuple
  3. Assertion errors: Updated test to match actual behavior - when no adapter with policy file is set, load_policies! returns an empty enforcer rather than an error tuple
  4. Policy lookup error: Corrected policy assertions to search for "admin" role instead of "alice" user to match the rbac.csv test data

The tests should now pass.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • esm.ubuntu.com
    • Triggering command: /usr/lib/apt/methods/https (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@nomeguy nomeguy closed this Nov 1, 2025
@sushilbansal

Copy link
Copy Markdown

hi @hsluoyz @nomeguy can this please be merged with master?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EctoAdapter: No Built-in Way to Load Policies from Database on Startup

5 participants