├── test ├── test_helper.exs ├── data │ ├── keymatch2.csv │ ├── acl_with_superuser.csv │ ├── acl_restful.csv │ ├── acl.conf │ ├── g3_with_domain.csv │ ├── rbac.csv │ ├── kv.conf │ ├── acl.csv │ ├── keymatch2.conf │ ├── acl_restful.conf │ ├── acl_with_superuser.conf │ ├── rbac.conf │ ├── rbac_domain.csv │ ├── rbac_domain.conf │ └── g3_with_domain.conf ├── enforcer_test.exs ├── model │ ├── matcher_test.exs │ ├── policy_effect_test.exs │ ├── policy_definition_test.exs │ ├── request_definition_test.exs │ └── config_test.exs ├── internal │ ├── digraph_test.exs │ ├── helpers_test.exs │ ├── role_group_test.exs │ └── partial_range_test.exs ├── model_test.exs ├── persist │ ├── filtered_policy_test.exs │ ├── readonly_file_adapter_test.exs │ ├── ecto_adapter_test.exs │ ├── ecto_acl_test.exs │ ├── ecto_rbac_domain_test.exs │ ├── ecto_rbac_test.exs │ ├── readonly_file_adapter_filtered_test.exs │ └── ecto_sandbox_transaction_test.exs ├── support │ └── mock_repo.ex └── enforcer │ ├── keymatch2_test.exs │ ├── rbac_domain_model_test.exs │ ├── g3_with_domain_test.exs │ ├── rbac_model_test.exs │ ├── acl_restful_model_test.exs │ └── acl_model_test.exs ├── .tool-versions ├── rbac.png ├── .formatter.exs ├── config └── config.exs ├── lib ├── acx │ ├── persist │ │ ├── persist_adapter.ex │ │ ├── readonly_file_adapter.ex │ │ └── ecto_adapter.ex │ ├── enforcer_supervisor.ex │ ├── model │ │ ├── request.ex │ │ ├── policy.ex │ │ ├── request_definition.ex │ │ ├── policy_effect.ex │ │ ├── policy_definition.ex │ │ ├── config.ex │ │ └── matcher.ex │ ├── internal │ │ ├── helpers.ex │ │ ├── role_group.ex │ │ ├── partial_range.ex │ │ ├── digraph.ex │ │ └── operator.ex │ └── enforcer_server.ex └── acx.ex ├── .gitignore ├── .releaserc.json ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── mix.exs ├── CONTRIBUTING.md ├── CHANGELOG.md ├── mix.lock ├── guides └── sandbox_testing.md ├── .credo.exs ├── LICENSE └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18.3-otp-27 2 | erlang 27.2 3 | -------------------------------------------------------------------------------- /rbac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/casbin/casbin-ex/HEAD/rbac.png -------------------------------------------------------------------------------- /test/data/keymatch2.csv: -------------------------------------------------------------------------------- 1 | p, alice, /alice_data/:resource, GET 2 | p, alice, /alice_data2/:id/using/:resId, GET 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/enforcer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.EnforcerTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | doctest Acx.Enforcer 5 | end 6 | -------------------------------------------------------------------------------- /test/data/acl_with_superuser.csv: -------------------------------------------------------------------------------- 1 | p, bob, blog_post, read 2 | 3 | p, peter, blog_post, create 4 | p, peter, blog_post, modify 5 | p, peter, blog_post, read -------------------------------------------------------------------------------- /test/model/matcher_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.MatcherTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Model.Matcher 4 | doctest Acx.Model.Matcher 5 | end 6 | -------------------------------------------------------------------------------- /test/internal/digraph_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Internal.DigraphTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Internal.Digraph 4 | doctest Acx.Internal.Digraph 5 | end 6 | -------------------------------------------------------------------------------- /test/internal/helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Internal.HelpersTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Internal.Helpers 4 | doctest Acx.Internal.Helpers 5 | end 6 | -------------------------------------------------------------------------------- /test/internal/role_group_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Internal.RoleGroupTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Internal.RoleGroup 4 | doctest Acx.Internal.RoleGroup 5 | end 6 | -------------------------------------------------------------------------------- /test/internal/partial_range_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Internal.PartialRangeTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Internal.PartialRange 4 | doctest Acx.Internal.PartialRange 5 | end 6 | -------------------------------------------------------------------------------- /test/model/policy_effect_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.PolicyEffectTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Model.{PolicyDefinition, PolicyEffect} 4 | doctest Acx.Model.PolicyEffect 5 | end 6 | -------------------------------------------------------------------------------- /test/model/policy_definition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.PolicyDefinitionTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Model.{Policy, PolicyDefinition} 4 | doctest Acx.Model.PolicyDefinition 5 | end 6 | -------------------------------------------------------------------------------- /test/data/acl_restful.csv: -------------------------------------------------------------------------------- 1 | p, alice, /alice_data/.*, GET 2 | p, alice, /alice_data/resource1, POST 3 | 4 | p, bob, /alice_data/resource2, GET 5 | p, bob, /bob_data/.*, POST 6 | 7 | p, peter, /peter_data, (GET)|(POST) 8 | -------------------------------------------------------------------------------- /test/model/request_definition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.RequestDefinitionTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Model.{Request, RequestDefinition} 4 | doctest Acx.Model.RequestDefinition 5 | end 6 | -------------------------------------------------------------------------------- /test/data/acl.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [policy_effect] 8 | e = some(where (p.eft == allow)) 9 | 10 | [matchers] 11 | m = r.sub == p.sub && r.obj == p.obj && r.act == p.act -------------------------------------------------------------------------------- /test/data/g3_with_domain.csv: -------------------------------------------------------------------------------- 1 | p, admin, /data/organizations/:orgid/, GET 2 | p, admin, /data/organizations/:orgid/, POST 3 | p, user, /data/organizations/:orgid/, GET 4 | 5 | g, alice, admin, * 6 | g, bob, admin, 1 7 | g, peter, admin, 2 8 | g, john, user, 2 9 | -------------------------------------------------------------------------------- /test/data/rbac.csv: -------------------------------------------------------------------------------- 1 | p, reader, blog_post, read 2 | p, author, blog_post, modify 3 | p, author, blog_post, create 4 | p, admin, blog_post, delete 5 | 6 | g, bob, reader 7 | g, peter, author 8 | g, alice, admin 9 | 10 | g, author, reader 11 | g, admin, author -------------------------------------------------------------------------------- /test/data/kv.conf: -------------------------------------------------------------------------------- 1 | # Request definiation 2 | r = sub, obj, act 3 | 4 | # Policy definition 5 | p = sub, obj, act 6 | 7 | # Effect definition 8 | e = some(where (p.eft == allow)) 9 | 10 | # Matcher definition 11 | m = r.sub == p.sub && r.obj == p.obj && r.act == p.act -------------------------------------------------------------------------------- /test/data/acl.csv: -------------------------------------------------------------------------------- 1 | p, alice, blog_post, create 2 | p, alice, blog_post, delete 3 | p, alice, blog_post, modify 4 | p, alice, blog_post, read 5 | 6 | p, bob, blog_post, read 7 | 8 | p, peter, blog_post, create 9 | p, peter, blog_post, modify 10 | p, peter, blog_post, read -------------------------------------------------------------------------------- /test/data/keymatch2.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [policy_effect] 8 | e = some(where (p.eft == allow)) 9 | 10 | [matchers] 11 | m = r.sub == p.sub && keyMatch2(r.obj, p.obj) && r.act == p.act 12 | -------------------------------------------------------------------------------- /test/data/acl_restful.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [policy_effect] 8 | e = some(where (p.eft == allow)) 9 | 10 | [matchers] 11 | m = r.sub == p.sub && regexMatch(r.obj, p.obj) && regexMatch(r.act, p.act) -------------------------------------------------------------------------------- /test/data/acl_with_superuser.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [policy_effect] 8 | e = some(where (p.eft == allow)) 9 | 10 | [matchers] 11 | m = r.sub == p.sub && r.obj == p.obj && r.act == p.act || r.sub == "root" -------------------------------------------------------------------------------- /test/data/rbac.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [role_definition] 8 | g = _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act -------------------------------------------------------------------------------- /test/model_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.ModelTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Model 4 | 5 | alias Acx.Model.{ 6 | Matcher, 7 | Policy, 8 | PolicyDefinition, 9 | PolicyEffect, 10 | Request, 11 | RequestDefinition 12 | } 13 | 14 | doctest Acx.Model 15 | end 16 | -------------------------------------------------------------------------------- /test/data/rbac_domain.csv: -------------------------------------------------------------------------------- 1 | p, admin, domain1, data1, read 2 | p, admin, domain1, data1, write 3 | p, admin, domain2, data2, read 4 | p, admin, domain2, data2, write 5 | p, user, domain3, data2, read 6 | 7 | g, alice, admin, domain1 8 | g, alice, admin, domain2 9 | g, bob, admin, domain2 10 | g, bob, user, domain3 11 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() == :dev do 4 | config :git_hooks, 5 | auto_install: true, 6 | verbose: true, 7 | hooks: [ 8 | pre_push: [ 9 | tasks: [ 10 | {:cmd, "mix credo --strict"}, 11 | {:cmd, "mix format"} 12 | ] 13 | ] 14 | ] 15 | end 16 | -------------------------------------------------------------------------------- /lib/acx/persist/persist_adapter.ex: -------------------------------------------------------------------------------- 1 | defprotocol Acx.Persist.PersistAdapter do 2 | def load_policies(adapter) 3 | def load_filtered_policy(adapter, filter) 4 | def add_policy(adapter, policy) 5 | def remove_policy(adapter, policy) 6 | def remove_filtered_policy(adapter, key, idx, attrs) 7 | def save_policies(adapter, policies) 8 | end 9 | -------------------------------------------------------------------------------- /test/data/rbac_domain.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, dom, obj, act 3 | 4 | [policy_definition] 5 | p = sub, dom, obj, act 6 | 7 | [role_definition] 8 | g = _, _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act -------------------------------------------------------------------------------- /test/data/g3_with_domain.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, dom, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [role_definition] 8 | g = _, _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | m = (g(r.sub, p.sub, r.dom) || g(r.sub, p.sub, "*")) && keyMatch2(r.obj, p.obj) && r.act == p.act 15 | -------------------------------------------------------------------------------- /lib/acx.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx do 2 | @moduledoc """ 3 | Acx is an Elixir implementation of the Casbin authorization library. 4 | """ 5 | use Application 6 | 7 | def start(_type, _args) do 8 | children = [ 9 | {Registry, keys: :unique, name: Acx.EnforcerRegistry}, 10 | Acx.EnforcerSupervisor 11 | ] 12 | 13 | :ets.new(:enforcers_table, [:public, :named_table]) 14 | 15 | opts = [strategy: :one_for_one, name: Acx.Supervisor] 16 | Supervisor.start_link(children, opts) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | acx-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["master", "main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/changelog", 7 | [ 8 | "@semantic-release/exec", 9 | { 10 | "prepareCmd": "sed -i 's/version: \"[0-9]\\.[0-9]\\+\\.[0-9]\\+\"/version: \"${nextRelease.version}\"/' mix.exs" 11 | } 12 | ], 13 | [ 14 | "@semantic-release/git", 15 | { 16 | "assets": ["CHANGELOG.md", "mix.exs"], 17 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 18 | } 19 | ], 20 | "@semantic-release/github" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /lib/acx/enforcer_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx.EnforcerSupervisor do 2 | @moduledoc """ 3 | A supervisor that starts `Enforcer` processes dynamically. 4 | """ 5 | 6 | use DynamicSupervisor 7 | 8 | def start_link(_args) do 9 | DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__) 10 | end 11 | 12 | def init(:ok) do 13 | DynamicSupervisor.init(strategy: :one_for_one) 14 | end 15 | 16 | @doc """ 17 | Starts a new `Enforcer` process and supervises it 18 | """ 19 | def start_enforcer(ename, cfile) do 20 | child_spec = %{ 21 | id: Acx.EnforcerServer, 22 | start: {Acx.EnforcerServer, :start_link, [ename, cfile]}, 23 | restart: :permanent 24 | } 25 | 26 | DynamicSupervisor.start_child(__MODULE__, child_spec) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/acx/model/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.Request do 2 | @moduledoc """ 3 | This module defines a structure to represent a request. 4 | A request has two parts: 5 | 6 | - A key, typically the atom `:r`. 7 | - A list of key-value pairs, in which `key` is the name of one of 8 | the attributes of the request, and `value` is the value of such 9 | attribute. 10 | """ 11 | 12 | defstruct key: nil, attrs: [] 13 | 14 | @type key() :: atom() 15 | @type attr() :: atom() 16 | @type attr_value() :: String.t() | number() 17 | @type t() :: %__MODULE__{ 18 | key: key(), 19 | attrs: [{attr(), attr_value()}] 20 | } 21 | 22 | @doc """ 23 | Creates a new request based on the given `key` and a list of attributes. 24 | """ 25 | def new(key, attrs) do 26 | %__MODULE__{key: key, attrs: attrs} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/persist/filtered_policy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Persist.FilteredPolicyTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | alias Acx.Persist.EctoAdapter 5 | alias Acx.Persist.PersistAdapter 6 | 7 | @cfile "../data/rbac_domain.conf" |> Path.expand(__DIR__) 8 | 9 | describe "load_filtered_policy/2 with EctoAdapter" do 10 | test "returns error when repo is not set" do 11 | adapter = EctoAdapter.new(nil) 12 | assert {:error, "repo is not set"} == PersistAdapter.load_filtered_policy(adapter, %{}) 13 | end 14 | end 15 | 16 | describe "load_filtered_policies!/2 with Enforcer" do 17 | test "raises error when repo is not set" do 18 | adapter = EctoAdapter.new(nil) 19 | {:ok, e} = Enforcer.init(@cfile, adapter) 20 | 21 | assert_raise ArgumentError, "repo is not set", fn -> 22 | Enforcer.load_filtered_policies!(e, %{v2: "domain1"}) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | 9 | jobs: 10 | release: 11 | name: Release 12 | runs-on: ubuntu-latest 13 | if: "!contains(github.event.head_commit.message, 'skip ci')" 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | persist-credentials: false 21 | 22 | - name: Set up Node.js 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: '18' 26 | 27 | - name: Install semantic-release 28 | run: | 29 | npm install -g \ 30 | semantic-release@21 \ 31 | @semantic-release/git@10 \ 32 | @semantic-release/changelog@6 \ 33 | @semantic-release/exec@6 34 | 35 | - name: Run semantic-release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | run: npx semantic-release 39 | -------------------------------------------------------------------------------- /test/persist/readonly_file_adapter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Persist.ReadonlyFileAdapterTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Persist.PersistAdapter 4 | alias Acx.Persist.ReadonlyFileAdapter 5 | doctest Acx.Persist.ReadonlyFileAdapter 6 | 7 | describe "given a policy file" do 8 | @pfile "../data/acl.csv" |> Path.expand(__DIR__) 9 | 10 | test "loads all of the policies" do 11 | expected = 12 | {:ok, 13 | [ 14 | ["p", "alice", "blog_post", "create"], 15 | ["p", "alice", "blog_post", "delete"], 16 | ["p", "alice", "blog_post", "modify"], 17 | ["p", "alice", "blog_post", "read"], 18 | ["p", "bob", "blog_post", "read"], 19 | ["p", "peter", "blog_post", "create"], 20 | ["p", "peter", "blog_post", "modify"], 21 | ["p", "peter", "blog_post", "read"] 22 | ]} 23 | 24 | loaded = 25 | ReadonlyFileAdapter.new(@pfile) 26 | |> PersistAdapter.load_policies() 27 | 28 | assert loaded === expected 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :acx, 7 | version: "1.6.0", 8 | elixir: "~> 1.13", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps() 12 | ] 13 | end 14 | 15 | # Run "mix help compile.app" to learn about applications. 16 | def application do 17 | [ 18 | extra_applications: [:logger], 19 | mod: {Acx, []} 20 | ] 21 | end 22 | 23 | # specifies which paths to compile per environment 24 | def elixirc_paths(:test), do: ["lib", "test/support"] 25 | def elixirc_paths(_), do: ["lib"] 26 | 27 | # Run "mix help deps" to learn about dependencies. 28 | defp deps do 29 | [ 30 | {:ecto_sql, "~> 3.10"}, 31 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 32 | {:git_hooks, "~> 0.7.3", only: [:dev], runtime: false} 33 | # {:dep_from_hexpm, "~> 0.3.0"}, 34 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 35 | ] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/acx/model/policy.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.Policy do 2 | @moduledoc """ 3 | This module defines a structure to represent a policy. 4 | A policy has two parts: 5 | 6 | - A key, typically the atom `:p`. 7 | - A list of key-value pairs, in which `key` is the name of one of 8 | the attributes of the policy, and `value` is the value of such 9 | attribute. 10 | - All policies have one common attribute named `:eft` and its value 11 | can only be either `"allow"` or `"deny"`. 12 | 13 | NOTE: 14 | """ 15 | 16 | defstruct key: nil, attrs: [] 17 | 18 | @type key() :: atom() 19 | @type attr() :: atom() 20 | @type attr_value() :: String.t() | number() 21 | @type t() :: %__MODULE__{ 22 | key: key(), 23 | attrs: [{attr(), attr_value()}] 24 | } 25 | 26 | @doc """ 27 | Create new policy based on the given `key` and the list of 28 | attributes `attrs`. 29 | """ 30 | def new(key, attrs) when is_atom(key) and is_list(attrs) do 31 | %__MODULE__{key: key, attrs: attrs} 32 | end 33 | 34 | @doc """ 35 | Returns `true` if the policy is of type `allow`. 36 | Returns `false` otherwise. 37 | """ 38 | def allow?(%__MODULE__{attrs: attrs}), do: attrs[:eft] === "allow" 39 | end 40 | -------------------------------------------------------------------------------- /test/persist/ecto_adapter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Persist.EctoAdapterTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Persist.EctoAdapter 4 | alias Acx.Persist.EctoAdapter.CasbinRule 5 | alias Acx.Persist.PersistAdapter 6 | doctest Acx.Persist.EctoAdapter 7 | doctest Acx.Persist.PersistAdapter.Acx.Persist.EctoAdapter 8 | doctest Acx.Persist.EctoAdapter.CasbinRule 9 | 10 | defmodule MockTestRepo do 11 | use Acx.Persist.MockRepo, pfile: "../data/acl.csv" |> Path.expand(__DIR__) 12 | end 13 | 14 | describe "using the mock repo" do 15 | @repo MockTestRepo 16 | 17 | test "loads policies from the database" do 18 | expected = 19 | {:ok, 20 | [ 21 | ["p", "alice", "blog_post", "create"], 22 | ["p", "alice", "blog_post", "delete"], 23 | ["p", "alice", "blog_post", "modify"], 24 | ["p", "alice", "blog_post", "read"], 25 | ["p", "bob", "blog_post", "read"], 26 | ["p", "peter", "blog_post", "create"], 27 | ["p", "peter", "blog_post", "modify"], 28 | ["p", "peter", "blog_post", "read"] 29 | ]} 30 | 31 | loaded = 32 | EctoAdapter.new(@repo) 33 | |> PersistAdapter.load_policies() 34 | 35 | assert loaded === expected 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - main 8 | push: 9 | branches: 10 | - master 11 | - main 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | elixir: ['1.14.2'] 20 | otp: ['25.1.1'] 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v3 25 | 26 | - name: Set up Elixir 27 | uses: erlef/setup-beam@v1 28 | with: 29 | elixir-version: ${{ matrix.elixir }} 30 | otp-version: ${{ matrix.otp }} 31 | 32 | - name: Restore dependencies cache 33 | uses: actions/cache@v3 34 | with: 35 | path: | 36 | deps 37 | _build 38 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 39 | restore-keys: ${{ runner.os }}-mix- 40 | 41 | - name: Install dependencies 42 | run: mix deps.get 43 | 44 | - name: Check code formatting 45 | run: mix format --check-formatted 46 | 47 | - name: Compile dependencies 48 | run: mix deps.compile 49 | 50 | - name: Compile application 51 | run: mix compile --warnings-as-errors 52 | 53 | - name: Run tests 54 | run: mix test 55 | -------------------------------------------------------------------------------- /test/persist/ecto_acl_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Persist.EctoAclTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | alias Acx.Persist.EctoAdapter 5 | 6 | defmodule MockAclRepo do 7 | use Acx.Persist.MockRepo, pfile: "../data/acl.csv" |> Path.expand(__DIR__) 8 | end 9 | 10 | @cfile "../data/acl.conf" |> Path.expand(__DIR__) 11 | @repo MockAclRepo 12 | 13 | setup do 14 | adapter = EctoAdapter.new(@repo) 15 | 16 | {:ok, e} = Enforcer.init(@cfile, adapter) 17 | e = Enforcer.load_policies!(e) 18 | {:ok, e: e} 19 | end 20 | 21 | describe "allow?/2" do 22 | @test_cases [ 23 | {["alice", "blog_post", "create"], true}, 24 | {["alice", "blog_post", "delete"], true}, 25 | {["alice", "blog_post", "modify"], true}, 26 | {["alice", "blog_post", "read"], true}, 27 | {["alice", "blog_post", "foo"], false}, 28 | {["alice", "data", "create"], false}, 29 | {["bob", "blog_post", "read"], true}, 30 | {["bob", "blog_post", "create"], false}, 31 | {["bob", "blog_post", "modify"], false}, 32 | {["bob", "blog_post", "delete"], false}, 33 | {["bob", "blog_post", "foo"], false}, 34 | {["peter", "blog_post", "create"], true}, 35 | {["peter", "blog_post", "modify"], true}, 36 | {["peter", "blog_post", "read"], true}, 37 | {["peter", "blog_post", "delete"], false}, 38 | {["peter", "blog_post", "foo"], false} 39 | ] 40 | 41 | Enum.each(@test_cases, fn {req, res} -> 42 | test "response #{res} for request #{inspect(req)}", %{e: e} do 43 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 44 | end 45 | end) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/mock_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx.Persist.MockRepo do 2 | @moduledoc """ 3 | Mock repository for testing Ecto adapter functionality. 4 | """ 5 | 6 | defmacro __using__(opts) do 7 | pfile = opts[:pfile] 8 | 9 | quote do 10 | alias Acx.Persist.EctoAdapter.CasbinRule 11 | alias Ecto.Changeset 12 | 13 | def to_changeset(id, rule) do 14 | Enum.zip([:ptype, :v0, :v1, :v2, :v3, :v4, :v5, :v6], rule) 15 | |> Map.new() 16 | |> then(&Map.merge(%Acx.Persist.EctoAdapter.CasbinRule{id: id}, &1)) 17 | end 18 | 19 | # Define function head with default parameter 20 | def all(query_or_schema, opts \\ []) 21 | 22 | def all(CasbinRule, _opts) do 23 | unquote(pfile) 24 | |> File.read!() 25 | |> String.split("\n", trim: true) 26 | |> Enum.map(&String.split(&1, ~r{,\s*})) 27 | |> Enum.with_index(1) 28 | |> Enum.map(fn {rule, id} -> to_changeset(id, rule) end) 29 | end 30 | 31 | # Support for Ecto.Query - delegates to CasbinRule for simplicity 32 | # In a real database, Ecto would apply the query filters 33 | # For testing filtered policies, use ReadonlyFileAdapter tests instead 34 | def all(%Ecto.Query{}, _opts) do 35 | all(CasbinRule) 36 | end 37 | 38 | def insert(changeset, opts \\ []) 39 | 40 | def insert(%Changeset{errors: [], changes: values}, _opts) do 41 | {:ok, struct(CasbinRule, values)} 42 | end 43 | 44 | def insert(changeset, _opts) do 45 | {:error, changeset} 46 | end 47 | 48 | def delete_all(queryset) do 49 | {1, nil} 50 | end 51 | 52 | # Allow override in using modules 53 | defoverridable all: 1, all: 2 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/persist/ecto_rbac_domain_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Persist.EctoRbacDomainTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | alias Acx.Persist.EctoAdapter 5 | 6 | @cfile "../data/rbac_domain.conf" |> Path.expand(__DIR__) 7 | 8 | defmodule MockAclRepo do 9 | use Acx.Persist.MockRepo, pfile: "../data/rbac_domain.csv" |> Path.expand(__DIR__) 10 | end 11 | 12 | @repo MockAclRepo 13 | 14 | setup do 15 | adapter = EctoAdapter.new(@repo) 16 | {:ok, e} = Enforcer.init(@cfile, adapter) 17 | 18 | e = 19 | Enforcer.load_policies!(e) 20 | |> Enforcer.load_mapping_policies!() 21 | 22 | {:ok, e: e} 23 | end 24 | 25 | describe "allow?/2" do 26 | @test_cases [ 27 | {["alice", "domain1", "data1", "read"], true}, 28 | {["alice", "domain1", "data1", "write"], true}, 29 | {["alice", "domain2", "data2", "read"], true}, 30 | {["alice", "domain2", "data2", "write"], true}, 31 | {["alice", "domain2", "data2", "no_existing"], false}, 32 | {["alice", "domain2", "no_existing", "read"], false}, 33 | {["alice", "domain3", "data2", "read"], false}, 34 | {["bob", "domain1", "data1", "read"], false}, 35 | {["bob", "domain1", "data1", "write"], false}, 36 | {["bob", "domain2", "data2", "read"], true}, 37 | {["bob", "domain2", "data2", "write"], true}, 38 | {["bob", "domain2", "data2", "no_existing"], false}, 39 | {["bob", "domain2", "no_existing", "read"], false}, 40 | {["bob", "domain3", "data2", "read"], true}, 41 | {["peter", "domain1", "data1", "read"], false}, 42 | {["peter", "domain1", "data1", "write"], false}, 43 | {["peter", "domain2", "data2", "read"], false}, 44 | {["peter", "domain2", "data2", "write"], false}, 45 | {["peter", "domain2", "data2", "no_existing"], false}, 46 | {["peter", "domain2", "no_existing", "read"], false}, 47 | {["peter", "domain3", "data2", "read"], false} 48 | ] 49 | 50 | Enum.each(@test_cases, fn {req, res} -> 51 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 52 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 53 | end 54 | end) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/model/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.ConfigTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Model.Config 4 | doctest Acx.Model.Config 5 | 6 | describe "new/1" do 7 | test "returns the correct key-value pairs under :undefined_section" do 8 | assert %Config{sections: sections} = 9 | "../data/kv.conf" 10 | |> Path.expand(__DIR__) 11 | |> Config.new() 12 | 13 | assert [{:undefined_section, kvs}] = sections 14 | 15 | assert kvs == [ 16 | r: "sub,obj,act", 17 | p: "sub,obj,act", 18 | e: "some(where(p.eft==allow))", 19 | m: "r.sub==p.sub&&r.obj==p.obj&&r.act==p.act" 20 | ] 21 | end 22 | 23 | test "returns correct number of sections for ACL config" do 24 | assert %Config{sections: sections} = 25 | "../data/acl.conf" 26 | |> Path.expand(__DIR__) 27 | |> Config.new() 28 | 29 | assert [ 30 | request_definition: requests, 31 | policy_definition: policies, 32 | policy_effect: effects, 33 | matchers: matchers 34 | ] = sections 35 | 36 | assert requests == [r: "sub,obj,act"] 37 | assert policies == [p: "sub,obj,act"] 38 | assert effects == [e: "some(where(p.eft==allow))"] 39 | assert matchers == [m: "r.sub==p.sub&&r.obj==p.obj&&r.act==p.act"] 40 | end 41 | 42 | test "returns correct number of sections for RBAC config" do 43 | assert %Config{sections: sections} = 44 | "../data/rbac.conf" 45 | |> Path.expand(__DIR__) 46 | |> Config.new() 47 | 48 | assert [ 49 | request_definition: requests, 50 | policy_definition: policies, 51 | role_definition: roles, 52 | policy_effect: effects, 53 | matchers: matchers 54 | ] = sections 55 | 56 | assert requests == [r: "sub,obj,act"] 57 | assert policies == [p: "sub,obj,act"] 58 | assert roles == [g: "_,_"] 59 | assert effects == [e: "some(where(p.eft==allow))"] 60 | assert matchers == [m: "g(r.sub,p.sub)&&r.obj==p.obj&&r.act==p.act"] 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/enforcer/keymatch2_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Enforcer.KeyMatch2Test do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | 5 | @cfile "../data/keymatch2.conf" |> Path.expand(__DIR__) 6 | @pfile "../data/keymatch2.csv" |> Path.expand(__DIR__) 7 | 8 | setup do 9 | {:ok, e} = Enforcer.init(@cfile) 10 | 11 | e = 12 | e 13 | |> Enforcer.load_policies!(@pfile) 14 | |> Enforcer.load_mapping_policies!(@pfile) 15 | 16 | {:ok, e: e} 17 | end 18 | 19 | describe "allow?/2" do 20 | @test_cases [ 21 | {["alice", "/alice_data/1", "GET"], true}, 22 | {["alice", "/alice_data2/1/using/2", "GET"], true}, 23 | {["alice", "/alice_data2/1/using/2", "POST"], false}, 24 | {["alice", "/alice_data2/1/using/2/admin/", "GET"], false}, 25 | {["alice", "/admin/alice_data2/1/using/2", "GET"], false}, 26 | {["bob", "/admin/alice_data2/1/using/2", "GET"], false} 27 | ] 28 | 29 | Enum.each(@test_cases, fn {req, res} -> 30 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 31 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 32 | end 33 | end) 34 | end 35 | 36 | describe "key_match2?/2" do 37 | @test_cases [ 38 | {"/foo", "/foo", true}, 39 | {"/foo", "/foo*", true}, 40 | {"/foo", "/foo/*", false}, 41 | {"/foo/bar", "/foo", false}, 42 | {"/foo/bar", "/foo*", false}, 43 | {"/foo/bar", "/foo/*", true}, 44 | {"/foobar", "/foo", false}, 45 | {"/foobar", "/foo*", false}, 46 | {"/foobar", "/foo/*", false}, 47 | {"/", "/:resource", false}, 48 | {"/resource1", "/:resource", true}, 49 | {"/myid", "/:id/using/:resId", false}, 50 | {"/myid/using/myresid", "/:id/using/:resId", true}, 51 | {"/proxy/myid", "/proxy/:id/*", false}, 52 | {"/proxy/myid/", "/proxy/:id/*", true}, 53 | {"/proxy/myid/res", "/proxy/:id/*", true}, 54 | {"/proxy/myid/res/res2", "/proxy/:id/*", true}, 55 | {"/proxy/myid/res/res2/res3", "/proxy/:id/*", true}, 56 | {"/proxy/", "/proxy/:id/*", false}, 57 | {"/alice", "/:id", true}, 58 | {"/alice/all", "/:id/all", true}, 59 | {"/alice", "/:id/all", false}, 60 | {"/alice/all", "/:id", false}, 61 | {"/alice/all", "/:/all", false} 62 | ] 63 | 64 | Enum.each(@test_cases, fn {key1, key2, res} -> 65 | test "response `#{res}` for combination `#{key1}` `#{key2}`" do 66 | assert Enforcer.key_match2?(unquote(key1), unquote(key2)) === unquote(res) 67 | end 68 | end) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/persist/ecto_rbac_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Persist.EctoRbacTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | alias Acx.Persist.EctoAdapter 5 | 6 | @cfile "../data/rbac.conf" |> Path.expand(__DIR__) 7 | 8 | defmodule MockAclRepo do 9 | use Acx.Persist.MockRepo, pfile: "../data/rbac.csv" |> Path.expand(__DIR__) 10 | end 11 | 12 | @repo MockAclRepo 13 | 14 | setup do 15 | adapter = EctoAdapter.new(@repo) 16 | {:ok, e} = Enforcer.init(@cfile, adapter) 17 | 18 | e = 19 | Enforcer.load_policies!(e) 20 | |> Enforcer.load_mapping_policies!() 21 | 22 | {:ok, e: e} 23 | end 24 | 25 | defp setup_delete_author_role(%{e: e} = ctx) do 26 | e = Enforcer.remove_mapping_policy(e, {:g, "author", "reader"}) 27 | %{ctx | e: e} 28 | end 29 | 30 | describe "allow?/2" do 31 | @test_cases [ 32 | {["bob", "blog_post", "read"], true}, 33 | {["bob", "blog_post", "create"], false}, 34 | {["bob", "blog_post", "modify"], false}, 35 | {["bob", "blog_post", "delete"], false}, 36 | {["peter", "blog_post", "read"], true}, 37 | {["peter", "blog_post", "create"], true}, 38 | {["peter", "blog_post", "modify"], true}, 39 | {["peter", "blog_post", "delete"], false}, 40 | {["alice", "blog_post", "read"], true}, 41 | {["alice", "blog_post", "create"], true}, 42 | {["alice", "blog_post", "modify"], true}, 43 | {["alice", "blog_post", "delete"], true} 44 | ] 45 | 46 | Enum.each(@test_cases, fn {req, res} -> 47 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 48 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 49 | end 50 | end) 51 | end 52 | 53 | describe "when removed mapping policy author -> reader" do 54 | setup [:setup_delete_author_role] 55 | 56 | @test_cases [ 57 | {["bob", "blog_post", "read"], true}, 58 | {["bob", "blog_post", "create"], false}, 59 | {["bob", "blog_post", "modify"], false}, 60 | {["bob", "blog_post", "delete"], false}, 61 | {["peter", "blog_post", "read"], false}, 62 | {["peter", "blog_post", "create"], true}, 63 | {["peter", "blog_post", "modify"], true}, 64 | {["peter", "blog_post", "delete"], false}, 65 | {["alice", "blog_post", "read"], false}, 66 | {["alice", "blog_post", "create"], true}, 67 | {["alice", "blog_post", "modify"], true}, 68 | {["alice", "blog_post", "delete"], true} 69 | ] 70 | 71 | Enum.each(@test_cases, fn {req, res} -> 72 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 73 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 74 | end 75 | end) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/acx/internal/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx.Internal.Helpers do 2 | @moduledoc """ 3 | Helper functions used internally by Acx. 4 | """ 5 | @doc """ 6 | Returns a tuple `{succeeds, remainder, count}` where `succeeds` is 7 | the initial segment of the given `list`, in which all elements have 8 | the property `p`, `remainder` is the remainder of the `list`, and `count` 9 | is the number of items in `succeeds`. 10 | 11 | If `reverse` is set to `true` the `succeeds` list will be reversed 12 | (default). 13 | 14 | ## Examples 15 | 16 | iex> Helpers.get_while(&is_atom/1, []) 17 | {[], [], 0} 18 | 19 | iex> Helpers.get_while(&is_atom/1, [:a, :b, 1, :c, :d]) 20 | {[:b, :a], [1, :c, :d], 2} 21 | 22 | iex> Helpers.get_while(&is_atom/1, [:a, :b, 1, :c, :d], false) 23 | {[:a, :b], [1, :c, :d], 2} 24 | """ 25 | def get_while(p, list, reverse \\ true) 26 | when is_function(p, 1) and is_list(list) do 27 | {succeeds, remainder, count} = get_while_reverse(p, list, [], 0) 28 | 29 | case reverse do 30 | true -> 31 | {succeeds, remainder, count} 32 | 33 | false -> 34 | {Enum.reverse(succeeds), remainder, count} 35 | end 36 | end 37 | 38 | defp get_while_reverse(p, [head | tail], ret, count) do 39 | case p.(head) do 40 | false -> 41 | {ret, [head | tail], count} 42 | 43 | true -> 44 | get_while_reverse(p, tail, [head | ret], count + 1) 45 | end 46 | end 47 | 48 | defp get_while_reverse(_p, [], ret, count) do 49 | {ret, [], count} 50 | end 51 | 52 | @doc """ 53 | Pops `n` number of items from the given stack. 54 | 55 | ## Examples 56 | 57 | iex> Helpers.pop_stack([], 0) 58 | {:ok, [], []} 59 | 60 | iex> Helpers.pop_stack([:a, :b, :c, :d], 2) 61 | {:ok, [:b, :a], [:c, :d]} 62 | 63 | iex> Helpers.pop_stack([:a, :b, :c, :d], 3) 64 | {:ok, [:c, :b, :a], [:d]} 65 | 66 | iex> Helpers.pop_stack([:a, :b, :c, :d], 4) 67 | {:ok, [:d, :c, :b, :a], []} 68 | 69 | iex> Helpers.pop_stack([:a, :b, :c, :d], 5) 70 | {:error, :not_enough_items} 71 | """ 72 | def pop_stack(stack, n) when is_list(stack) and is_integer(n) and n >= 0 do 73 | pop_stack(stack, [], n) 74 | end 75 | 76 | defp pop_stack(rem, succeeds, 0), do: {:ok, succeeds, rem} 77 | 78 | defp pop_stack([], _, _), do: {:error, :not_enough_items} 79 | 80 | defp pop_stack([head | tail], succeeds, n) do 81 | pop_stack(tail, [head | succeeds], n - 1) 82 | end 83 | 84 | @doc """ 85 | Returns `true` if the given keyword list has duplicate keys in it, 86 | `false` otherwise. 87 | """ 88 | def has_duplicate_key?(list) do 89 | keys = Keyword.keys(list) 90 | length(keys) != length(Enum.uniq(keys)) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/enforcer/rbac_domain_model_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Enforcer.RbacDomainModelTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | 5 | @cfile "../data/rbac_domain.conf" |> Path.expand(__DIR__) 6 | @pfile "../data/rbac_domain.csv" |> Path.expand(__DIR__) 7 | 8 | setup do 9 | {:ok, e} = Enforcer.init(@cfile) 10 | 11 | e = 12 | e 13 | |> Enforcer.load_policies!(@pfile) 14 | |> Enforcer.load_mapping_policies!(@pfile) 15 | 16 | {:ok, e: e} 17 | end 18 | 19 | describe "allow?/2" do 20 | @test_cases [ 21 | {["alice", "domain1", "data1", "read"], true}, 22 | {["alice", "domain1", "data1", "write"], true}, 23 | {["alice", "domain2", "data2", "read"], true}, 24 | {["alice", "domain2", "data2", "write"], true}, 25 | {["alice", "domain2", "data2", "no_existing"], false}, 26 | {["alice", "domain2", "no_existing", "read"], false}, 27 | {["alice", "domain3", "data2", "read"], false}, 28 | {["bob", "domain1", "data1", "read"], false}, 29 | {["bob", "domain1", "data1", "write"], false}, 30 | {["bob", "domain2", "data2", "read"], true}, 31 | {["bob", "domain2", "data2", "write"], true}, 32 | {["bob", "domain2", "data2", "no_existing"], false}, 33 | {["bob", "domain2", "no_existing", "read"], false}, 34 | {["bob", "domain3", "data2", "read"], true}, 35 | {["peter", "domain1", "data1", "read"], false}, 36 | {["peter", "domain1", "data1", "write"], false}, 37 | {["peter", "domain2", "data2", "read"], false}, 38 | {["peter", "domain2", "data2", "write"], false}, 39 | {["peter", "domain2", "data2", "no_existing"], false}, 40 | {["peter", "domain2", "no_existing", "read"], false}, 41 | {["peter", "domain3", "data2", "read"], false} 42 | ] 43 | 44 | Enum.each(@test_cases, fn {req, res} -> 45 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 46 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 47 | end 48 | end) 49 | end 50 | 51 | describe "removed role allow?/2" do 52 | @test_cases [ 53 | {["alice", "domain1", "data1", "read"], false}, 54 | {["alice", "domain1", "data1", "write"], false}, 55 | {["alice", "domain2", "data2", "read"], true}, 56 | {["alice", "domain2", "data2", "write"], true}, 57 | {["alice", "domain2", "data2", "no_existing"], false}, 58 | {["alice", "domain2", "no_existing", "read"], false}, 59 | {["alice", "domain3", "data2", "read"], false} 60 | ] 61 | 62 | Enum.each(@test_cases, fn {req, res} -> 63 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 64 | e = Enforcer.remove_mapping_policy(e, {:g, "alice", "admin", "domain1"}) 65 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 66 | end 67 | end) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/acx/model/request_definition.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.RequestDefinition do 2 | @moduledoc """ 3 | This module defines a structure to represent a request definition in 4 | a model. A request definition has two parts: 5 | 6 | - A key, typically the atom `:r`. 7 | - And a list of attributes in which each attibute is an atom. 8 | """ 9 | 10 | defstruct key: nil, attrs: [] 11 | 12 | @type key() :: atom() 13 | @type attr() :: atom() 14 | @type attr_value() :: String.t() | number() 15 | @type t() :: %__MODULE__{ 16 | key: key(), 17 | attrs: [attr()] 18 | } 19 | 20 | alias Acx.Model.Request 21 | 22 | @doc """ 23 | Creates a request definition based on the given `key` and a comma 24 | separated string of attributes. 25 | 26 | ## Examples 27 | 28 | iex> rd = RequestDefinition.new(:r, "sub, obj, act") 29 | ...> %RequestDefinition{key: :r, attrs: attrs} = rd 30 | ...> attrs 31 | [:sub, :obj, :act] 32 | """ 33 | @spec new(key(), String.t()) :: t() 34 | def new(key, attr_str) when is_atom(key) and is_binary(attr_str) do 35 | attrs = 36 | attr_str 37 | |> String.trim() 38 | |> String.split(~r{,\s*}, trim: true) 39 | |> Enum.map(&String.to_atom/1) 40 | |> Enum.uniq() 41 | 42 | %__MODULE__{key: key, attrs: attrs} 43 | end 44 | 45 | @doc """ 46 | Creates a new request based on the given request definition and a list 47 | of values for attributes. 48 | 49 | ## Examples 50 | 51 | iex> rd = RequestDefinition.new(:r, "sub, obj, act") 52 | ...> attr_values = ["alice", "data1", "read"] 53 | ...> {:ok, req} = rd |> RequestDefinition.create_request(attr_values) 54 | ...> %Request{key: :r, attrs: attrs} = req 55 | ...> attrs 56 | [sub: "alice", obj: "data1", act: "read"] 57 | 58 | iex> rd = RequestDefinition.new(:r, "sub, obj, act") 59 | ...> values = ["alice", "data1"] 60 | ...> {:error, reason} = rd |> RequestDefinition.create_request(values) 61 | ...> reason 62 | "invalid request" 63 | """ 64 | @spec create_request(t(), [attr_value()]) :: 65 | {:ok, Request.t()} 66 | | {:error, String.t()} 67 | def create_request(%__MODULE__{} = rd, attr_values) 68 | when is_list(attr_values) do 69 | case valid_request?(rd, attr_values) do 70 | false -> 71 | {:error, "invalid request"} 72 | 73 | true -> 74 | %{key: key, attrs: attrs} = rd 75 | {:ok, Request.new(key, Enum.zip(attrs, attr_values))} 76 | end 77 | end 78 | 79 | defp valid_request?(%__MODULE__{attrs: attrs}, attr_values) do 80 | length(attrs) === length(attr_values) && 81 | Enum.all?(attr_values, &valid_attr_value_type?/1) 82 | end 83 | 84 | defp valid_attr_value_type?(value) do 85 | is_binary(value) || is_number(value) || is_map(value) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/enforcer/g3_with_domain_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Enforcer.G3WithDomain do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | 5 | @cfile "../data/g3_with_domain.conf" |> Path.expand(__DIR__) 6 | @pfile "../data/g3_with_domain.csv" |> Path.expand(__DIR__) 7 | 8 | setup do 9 | {:ok, e} = Enforcer.init(@cfile) 10 | 11 | e = 12 | e 13 | |> Enforcer.load_policies!(@pfile) 14 | |> Enforcer.load_mapping_policies!(@pfile) 15 | 16 | {:ok, e: e} 17 | end 18 | 19 | describe "allow?/2" do 20 | @test_cases [ 21 | {["alice", "1", "/data/organizations/1/", "GET"], true}, 22 | {["alice", "1", "/data/organizations/1/", "POST"], true}, 23 | {["alice", "2", "/data/organizations/2/", "GET"], true}, 24 | {["alice", "2", "/data/organizations/2/", "POST"], true}, 25 | {["alice", "3", "/data/organizations/3/", "GET"], true}, 26 | {["alice", "3", "/data/organizations/3/", "POST"], true}, 27 | {["alice", "1", "/data/organizations/1/", "no_existing"], false}, 28 | {["alice", "1", "no_existing", "GET"], false}, 29 | {["bob", "1", "/data/organizations/1/", "GET"], true}, 30 | {["bob", "1", "/data/organizations/1/", "POST"], true}, 31 | {["bob", "2", "/data/organizations/2/", "GET"], false}, 32 | {["bob", "2", "/data/organizations/2/", "POST"], false}, 33 | {["bob", "3", "/data/organizations/3/", "GET"], false}, 34 | {["bob", "3", "/data/organizations/3/", "POST"], false}, 35 | {["bob", "1", "/data/organizations/1/", "no_existing"], false}, 36 | {["bob", "1", "no_existing", "GET"], false}, 37 | {["peter", "1", "/data/organizations/1/", "GET"], false}, 38 | {["peter", "1", "/data/organizations/1/", "POST"], false}, 39 | {["peter", "2", "/data/organizations/2/", "GET"], true}, 40 | {["peter", "2", "/data/organizations/2/", "POST"], true}, 41 | {["peter", "3", "/data/organizations/3/", "GET"], false}, 42 | {["peter", "3", "/data/organizations/3/", "POST"], false}, 43 | {["peter", "1", "/data/organizations/1/", "no_existing"], false}, 44 | {["peter", "1", "no_existing", "GET"], false}, 45 | {["john", "1", "/data/organizations/1/", "GET"], false}, 46 | {["john", "1", "/data/organizations/1/", "POST"], false}, 47 | {["john", "2", "/data/organizations/2/", "GET"], true}, 48 | {["john", "2", "/data/organizations/2/", "POST"], false}, 49 | {["john", "3", "/data/organizations/3/", "GET"], false}, 50 | {["john", "3", "/data/organizations/3/", "POST"], false}, 51 | {["john", "1", "/data/organizations/1/", "no_existing"], false}, 52 | {["john", "1", "no_existing", "GET"], false} 53 | ] 54 | 55 | Enum.each(@test_cases, fn {req, res} -> 56 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 57 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 58 | end 59 | end) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/enforcer/rbac_model_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Acx.Enforcer.RbacModelTest do 2 | use ExUnit.Case, async: true 3 | alias Acx.Enforcer 4 | 5 | @cfile "../data/rbac.conf" |> Path.expand(__DIR__) 6 | @pfile "../data/rbac.csv" |> Path.expand(__DIR__) 7 | 8 | setup do 9 | {:ok, e} = Enforcer.init(@cfile) 10 | 11 | e = 12 | e 13 | |> Enforcer.load_policies!(@pfile) 14 | |> Enforcer.load_mapping_policies!(@pfile) 15 | 16 | {:ok, e: e} 17 | end 18 | 19 | describe "allow?/2" do 20 | @test_cases [ 21 | {["bob", "blog_post", "read"], true}, 22 | {["bob", "blog_post", "create"], false}, 23 | {["bob", "blog_post", "modify"], false}, 24 | {["bob", "blog_post", "delete"], false}, 25 | {["peter", "blog_post", "read"], true}, 26 | {["peter", "blog_post", "create"], true}, 27 | {["peter", "blog_post", "modify"], true}, 28 | {["peter", "blog_post", "delete"], false}, 29 | {["alice", "blog_post", "read"], true}, 30 | {["alice", "blog_post", "create"], true}, 31 | {["alice", "blog_post", "modify"], true}, 32 | {["alice", "blog_post", "delete"], true} 33 | ] 34 | 35 | Enum.each(@test_cases, fn {req, res} -> 36 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 37 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 38 | end 39 | end) 40 | end 41 | 42 | describe "removed mapping policy allow?/2" do 43 | @test_cases [ 44 | {["bob", "blog_post", "read"], true}, 45 | {["bob", "blog_post", "create"], false}, 46 | {["bob", "blog_post", "modify"], false}, 47 | {["bob", "blog_post", "delete"], false}, 48 | {["peter", "blog_post", "read"], false}, 49 | {["peter", "blog_post", "create"], false}, 50 | {["peter", "blog_post", "modify"], false}, 51 | {["peter", "blog_post", "delete"], false}, 52 | {["alice", "blog_post", "read"], true}, 53 | {["alice", "blog_post", "create"], true}, 54 | {["alice", "blog_post", "modify"], true}, 55 | {["alice", "blog_post", "delete"], true} 56 | ] 57 | 58 | Enum.each(@test_cases, fn {req, res} -> 59 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 60 | e = Enforcer.remove_mapping_policy(e, {:g, "peter", "author"}) 61 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 62 | end 63 | end) 64 | end 65 | 66 | describe "removed intermediate mapping policy allow?/2" do 67 | @test_cases [ 68 | {["bob", "blog_post", "read"], true}, 69 | {["bob", "blog_post", "create"], false}, 70 | {["bob", "blog_post", "modify"], false}, 71 | {["bob", "blog_post", "delete"], false}, 72 | {["peter", "blog_post", "read"], false}, 73 | {["peter", "blog_post", "create"], true}, 74 | {["peter", "blog_post", "modify"], true}, 75 | {["peter", "blog_post", "delete"], false}, 76 | {["alice", "blog_post", "read"], false}, 77 | {["alice", "blog_post", "create"], true}, 78 | {["alice", "blog_post", "modify"], true}, 79 | {["alice", "blog_post", "delete"], true} 80 | ] 81 | 82 | Enum.each(@test_cases, fn {req, res} -> 83 | test "response `#{res}` for request #{inspect(req)}", %{e: e} do 84 | e = Enforcer.remove_mapping_policy(e, {:g, "author", "reader"}) 85 | assert e |> Enforcer.allow?(unquote(req)) === unquote(res) 86 | end 87 | end) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/acx/model/policy_effect.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx.Model.PolicyEffect do 2 | @moduledoc """ 3 | This module defines a structure to represent a policy effect in a model. 4 | Policy effect defines whether the access should be approved or denied 5 | if multiple policy rules match the request. 6 | 7 | For now, only the following policy effect rules are valid: 8 | 9 | 1. `"some(where(p.eft==allow))"`: if there's any matched policy rule of 10 | type `allow`, the final effect is `allow`. Which means if there's no 11 | match or all matches are of type `deny`, the final effect is `deny`. 12 | 13 | 2. `"!some(where(p.eft==deny))"`: if there's no matched policy rules of 14 | type `deny`, the final effect is `allow`. 15 | """ 16 | 17 | defstruct rule: nil 18 | 19 | @type rule() :: String.t() 20 | @type t() :: %__MODULE__{ 21 | rule: rule() 22 | } 23 | 24 | @allow_override "some(where(p.eft==allow))" 25 | @deny_override "!some(where(p.eft==deny))" 26 | 27 | alias Acx.Model.Policy 28 | 29 | @doc """ 30 | Create a new policy effect based on the given `rule` string. 31 | 32 | ## Examples 33 | 34 | iex> pe = PolicyEffect.new("some(where(p.eft==allow))") 35 | ...> %PolicyEffect{rule: rule} = pe 36 | ...> rule 37 | "some(where(p.eft==allow))" 38 | 39 | iex> pe = PolicyEffect.new("!some(where(p.eft==deny))") 40 | ...> %PolicyEffect{rule: rule} = pe 41 | ...> rule 42 | "!some(where(p.eft==deny))" 43 | """ 44 | @spec new(String.t()) :: t() 45 | def new(rule) when rule in [@allow_override, @deny_override] do 46 | %__MODULE__{rule: rule} 47 | end 48 | 49 | @doc """ 50 | Determine whether a request is approved or denied when there are 51 | multiple policy rules match the request. 52 | 53 | ## Examples 54 | 55 | iex> pe = PolicyEffect.new("some(where(p.eft==allow))") 56 | ...> pe |> PolicyEffect.allow?([]) 57 | false 58 | 59 | iex> pe = PolicyEffect.new("some(where(p.eft==allow))") 60 | ...> pd = PolicyDefinition.new(:p, "sub, obj, act") 61 | ...> values = ["alice", "data1", "read"] 62 | ...> {:ok, p1} = pd |> PolicyDefinition.create_policy(values) 63 | ...> {:ok, p2} = pd |> PolicyDefinition.create_policy(values++["deny"]) 64 | ...> true = pe |> PolicyEffect.allow?([p1]) 65 | ...> false = pe |> PolicyEffect.allow?([p2]) 66 | ...> pe |> PolicyEffect.allow?([p1, p2]) 67 | true 68 | 69 | iex> pe = PolicyEffect.new("!some(where(p.eft==deny))") 70 | ...> pe |> PolicyEffect.allow?([]) 71 | true 72 | 73 | iex> pe = PolicyEffect.new("!some(where(p.eft==deny))") 74 | ...> pd = PolicyDefinition.new(:p, "sub, obj, act") 75 | ...> values = ["alice", "data1", "read"] 76 | ...> {:ok, p1} = pd |> PolicyDefinition.create_policy(values) 77 | ...> {:ok, p2} = pd |> PolicyDefinition.create_policy(values++["deny"]) 78 | ...> true = pe |> PolicyEffect.allow?([p1]) 79 | ...> false = pe |> PolicyEffect.allow?([p2]) 80 | ...> pe |> PolicyEffect.allow?([p1, p2]) 81 | false 82 | """ 83 | @spec allow?(t(), [Policy.t()]) :: boolean() 84 | def allow?(%__MODULE__{rule: @allow_override}, matched_policies) 85 | when is_list(matched_policies) do 86 | Enum.any?(matched_policies, &Policy.allow?/1) 87 | end 88 | 89 | def allow?(%__MODULE__{rule: @deny_override}, matched_policies) 90 | when is_list(matched_policies) do 91 | Enum.all?(matched_policies, &Policy.allow?/1) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/acx/persist/readonly_file_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Acx.Persist.ReadonlyFileAdapter do 2 | @moduledoc """ 3 | A read-only file adapter for loading policies from files. 4 | """ 5 | alias Acx.Persist.PersistAdapter 6 | 7 | defstruct policy_file: nil 8 | 9 | def new do 10 | %__MODULE__{policy_file: nil} 11 | end 12 | 13 | def new(pfile) do 14 | %__MODULE__{policy_file: pfile} 15 | end 16 | 17 | defimpl PersistAdapter, for: Acx.Persist.ReadonlyFileAdapter do 18 | def load_policies(%Acx.Persist.ReadonlyFileAdapter{policy_file: nil}) do 19 | {:ok, []} 20 | end 21 | 22 | def load_policies(adapter) do 23 | policies = 24 | adapter.policy_file 25 | |> File.read!() 26 | |> String.split("\n", trim: true) 27 | |> Enum.map(&String.split(&1, ~r{,\s*})) 28 | 29 | {:ok, policies} 30 | end 31 | 32 | def load_policies(_adapter, pfile) do 33 | policies = 34 | File.read!(pfile) 35 | |> String.split("\n", trim: true) 36 | |> Enum.map(&String.split(&1, ~r{,\s*})) 37 | 38 | {:ok, policies} 39 | end 40 | 41 | @doc """ 42 | Loads filtered policies from a file. 43 | 44 | The filter is applied in-memory after loading all policies. 45 | Note: For file-based adapters, filtering does not improve performance 46 | as all data must be read from disk anyway. 47 | 48 | ## Examples 49 | 50 | filter = %{ptype: "p", v3: "org:tenant_123"} 51 | PersistAdapter.load_filtered_policy(adapter, filter) 52 | """ 53 | def load_filtered_policy(%Acx.Persist.ReadonlyFileAdapter{policy_file: nil}, _filter) do 54 | {:ok, []} 55 | end 56 | 57 | def load_filtered_policy(adapter, filter) when is_map(filter) do 58 | case load_policies(adapter) do 59 | {:ok, policies} -> 60 | filtered_policies = apply_filter(policies, filter) 61 | {:ok, filtered_policies} 62 | 63 | error -> 64 | error 65 | end 66 | end 67 | 68 | defp apply_filter(policies, filter) do 69 | Enum.filter(policies, fn policy -> 70 | matches_filter?(policy, filter) 71 | end) 72 | end 73 | 74 | defp matches_filter?(policy, filter) do 75 | Enum.all?(filter, fn {key, value} -> 76 | policy_value = get_policy_value(policy, key) 77 | matches_value?(policy_value, value) 78 | end) 79 | end 80 | 81 | defp get_policy_value([ptype | _values], :ptype), do: ptype 82 | 83 | defp get_policy_value([_ptype | values], key) do 84 | index = 85 | case key do 86 | :v0 -> 0 87 | :v1 -> 1 88 | :v2 -> 2 89 | :v3 -> 3 90 | :v4 -> 4 91 | :v5 -> 5 92 | :v6 -> 6 93 | _ -> nil 94 | end 95 | 96 | if index && index < length(values) do 97 | Enum.at(values, index) 98 | else 99 | nil 100 | end 101 | end 102 | 103 | defp matches_value?(policy_value, filter_value) when is_list(filter_value) do 104 | policy_value in filter_value 105 | end 106 | 107 | defp matches_value?(policy_value, filter_value) do 108 | policy_value == filter_value 109 | end 110 | 111 | def add_policy(adapter, _policy) do 112 | {:ok, adapter} 113 | end 114 | 115 | def save_policies(adapter, _policies) do 116 | {:ok, adapter} 117 | end 118 | 119 | def remove_policy(adapter, _policy) do 120 | {:ok, adapter} 121 | end 122 | 123 | def remove_filtered_policy(adapter, _key, _idx, _attrs) do 124 | {:ok, adapter} 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to casbin-ex 2 | 3 | Thank you for your interest in contributing to casbin-ex! This document provides guidelines and information about the development workflow. 4 | 5 | ## Development Setup 6 | 7 | ### Prerequisites 8 | 9 | - Elixir 1.14.2 or higher 10 | - Erlang/OTP 25.1.1 or higher 11 | 12 | ### Getting Started 13 | 14 | 1. Fork and clone the repository 15 | 2. Install dependencies: 16 | ```bash 17 | mix deps.get 18 | ``` 19 | 3. Run tests to ensure everything is working: 20 | ```bash 21 | mix test 22 | ``` 23 | 24 | ## Code Quality 25 | 26 | ### Formatting 27 | 28 | We use the standard Elixir formatter. Before submitting a PR, ensure your code is properly formatted: 29 | 30 | ```bash 31 | mix format 32 | ``` 33 | 34 | ### Testing 35 | 36 | All new features and bug fixes should include tests. Run the test suite with: 37 | 38 | ```bash 39 | mix test 40 | ``` 41 | 42 | ## Continuous Integration 43 | 44 | ### CI Workflow 45 | 46 | Every pull request automatically triggers our CI workflow which: 47 | 48 | - Sets up Elixir and Erlang environment 49 | - Installs dependencies 50 | - Checks code formatting with `mix format --check-formatted` 51 | - Runs the full test suite with `mix test` 52 | 53 | All checks must pass before a PR can be merged. 54 | 55 | ## Commit Message Convention 56 | 57 | We use [Conventional Commits](https://www.conventionalcommits.org/) for automatic versioning and changelog generation. 58 | 59 | ### Commit Message Format 60 | 61 | ``` 62 | (): 63 | 64 | 65 | 66 |