diff --git a/README.md b/README.md index 05065bb5..1a17cc74 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ It stores flag information in Redis or a relational DB (PostgreSQL, MySQL, or SQ - [Gate Priority and Interactions](#gate-priority-and-interactions) - [Boolean Gate](#boolean-gate) - [Actor Gate](#actor-gate) + - [Hierarchical Actors](#hierarchical-actors) - [Group Gate](#group-gate) - [Percentage of Time Gate](#percentage-of-time-gate) - [Percentage of Actors Gate](#percentage-of-actors-gate) @@ -188,6 +189,59 @@ defimpl FunWithFlags.Actor, for: MyApp.Country do end ``` +### Hierarchical Actors + +FunWithFlags supports checking flags against a hierarchy of actors, where the first actor with an explicit setting (enabled or disabled) takes precedence. This is useful for scenarios like organization → user hierarchies, where a flag might be enabled for an organization but disabled for a specific user within that organization. + +```elixir +defmodule MyApp.User do + defstruct [:id, :name, :organization_id] +end + +defmodule MyApp.Organization do + defstruct [:id, :name] +end + +defimpl FunWithFlags.Actor, for: MyApp.User do + def id(%{id: id}) do + "user:#{id}" + end +end + +defimpl FunWithFlags.Actor, for: MyApp.Organization do + def id(%{id: id}) do + "org:#{id}" + end +end + +user = %MyApp.User{id: 1, name: "Alice", organization_id: 100} +org = %MyApp.Organization{id: 100, name: "Acme Corp"} + +# Enable the flag for the organization +FunWithFlags.disable(:new_feature) +FunWithFlags.enable(:new_feature, for_actor: org) + +# Check hierarchy: user first, then organization +FunWithFlags.enabled?(:new_feature, for_hierarchy: [user, org]) +# => true (inherits from organization since user has no explicit setting) + +# Override at the user level +FunWithFlags.disable(:new_feature, for_actor: user) +FunWithFlags.enabled?(:new_feature, for_hierarchy: [user, org]) +# => false (user setting overrides organization setting) +``` + +The hierarchy is processed in order: +1. For each actor in the list, check for explicit actor gates first +2. If no actor gate is found, check for group gates for that actor +3. If no setting is found for an actor, move to the next actor in the hierarchy +4. If no actor in the hierarchy has a setting, fall back to the boolean or percentage gates. + +This enables flexible authorization patterns like: +- **User → Team → Organization → Global** +- **Request → User → Account → Feature rollout** +- **Device → User → Group → Default** + ### Group Gate Group gates are similar to actor gates, but they apply to a category of entities rather than specific ones. They can be toggled on or off for the _name of the group_ instead of a specific term. diff --git a/lib/fun_with_flags.ex b/lib/fun_with_flags.ex index 30cf3c0b..f0ed0741 100644 --- a/lib/fun_with_flags.ex +++ b/lib/fun_with_flags.ex @@ -43,10 +43,13 @@ defmodule FunWithFlags do * `:for` - used to provide a term for which the flag could have a specific value. The passed term should implement the `Actor` or `Group` protocol, or both. + * `:for_hierarchy` - used to provide a list of terms in hierarchical order. + The first actor with an explicit setting will be used. Each term should + implement the `Actor` or `Group` protocol, or both. ## Examples - This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) + These examples rely on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_structs.ex) used in the tests. iex> alias FunWithFlags.TestUser, as: User @@ -69,6 +72,20 @@ defmodule FunWithFlags do iex> FunWithFlags.enabled?(:magic_wands, for: filch) false + ### Hierarchical Actors Example + + iex> alias FunWithFlags.TestUser, as: User + iex> alias FunWithFlags.TestOrg, as: Org + iex> org = %Org{id: 1, name: "Hogwarts"} + iex> user = %User{id: 1, name: "Harry Potter"} + iex> FunWithFlags.disable(:new_feature) + iex> FunWithFlags.enable(:new_feature, for_actor: org) + iex> FunWithFlags.enabled?(:new_feature, for_hierarchy: [user, org]) + true + iex> FunWithFlags.disable(:new_feature, for_actor: user) + iex> FunWithFlags.enabled?(:new_feature, for_hierarchy: [user, org]) + false + """ @spec enabled?(atom, options) :: boolean def enabled?(flag_name, options \\ []) @@ -87,6 +104,11 @@ defmodule FunWithFlags do Flag.enabled?(flag, for: item) end + def enabled?(flag_name, [for_hierarchy: actors]) when is_atom(flag_name) and is_list(actors) do + {:ok, flag} = @store.lookup(flag_name) + Flag.enabled?(flag, for_hierarchy: actors) + end + @doc """ Enables a feature flag. @@ -127,7 +149,7 @@ defmodule FunWithFlags do ### Enable for a group - This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) + This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_structs.ex) used in the tests. iex> alias FunWithFlags.TestUser, as: User @@ -270,7 +292,7 @@ defmodule FunWithFlags do ### Disable for a group - This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_user.ex) + This example relies on the [reference implementation](https://github.com/tompave/fun_with_flags/blob/master/test/support/test_structs.ex) used in the tests. iex> alias FunWithFlags.TestUser, as: User diff --git a/lib/fun_with_flags/flag.ex b/lib/fun_with_flags/flag.ex index 535185ff..3e856b1e 100644 --- a/lib/fun_with_flags/flag.ex +++ b/lib/fun_with_flags/flag.ex @@ -46,6 +46,10 @@ defmodule FunWithFlags.Flag do end end + def enabled?(flag = %__MODULE__{}, [for_hierarchy: actors]) do + check_hierarchy(flag, actors) + end + defp check_percentage_gate(gates, item, flag_name) do case percentage_of_actors_gate(gates) do @@ -135,4 +139,20 @@ defmodule FunWithFlags.Flag do defp percentage_of_actors_gate(gates) do Enum.find(gates, &Gate.percentage_of_actors?/1) end + + defp check_hierarchy(flag, []) do + enabled?(flag, []) + end + + defp check_hierarchy(flag = %__MODULE__{gates: gates}, [actor | rest_actors]) do + case check_actor_gates(gates, actor) do + {:ok, bool} -> bool + :ignore -> + case check_group_gates(gates, actor) do + {:ok, bool} -> bool + :ignore -> + check_hierarchy(flag, rest_actors) + end + end + end end diff --git a/test/fun_with_flags_test.exs b/test/fun_with_flags_test.exs index 40073873..f74d335c 100644 --- a/test/fun_with_flags_test.exs +++ b/test/fun_with_flags_test.exs @@ -965,4 +965,115 @@ defmodule FunWithFlagsTest do :telemetry.detach(ref) end end + + + describe "enabled?(name, for_hierarchy: actors)" do + setup do + user = %FunWithFlags.TestUser{id: 1, name: "Harry Potter", groups: [:wizards]} + org = %FunWithFlags.TestOrg{id: 100, name: "Hogwarts", groups: [:schools]} + {:ok, user: user, org: org, flag_name: unique_atom()} + end + + test "it returns false for non existing feature flags", %{user: user, org: org, flag_name: flag_name} do + refute FunWithFlags.enabled?(flag_name, for_hierarchy: [user, org]) + end + + test "it checks actors in order and returns first explicit setting", %{user: user, org: org, flag_name: flag_name} do + FunWithFlags.disable(flag_name) + FunWithFlags.enable(flag_name, for_actor: org) + + assert FunWithFlags.enabled?(flag_name, for_hierarchy: [user, org]) + end + + test "user setting overrides organization setting", %{user: user, org: org, flag_name: flag_name} do + FunWithFlags.disable(flag_name) + FunWithFlags.enable(flag_name, for_actor: org) + FunWithFlags.disable(flag_name, for_actor: user) + + refute FunWithFlags.enabled?(flag_name, for_hierarchy: [user, org]) + end + + test "it falls back to boolean gate when no actors have explicit settings", %{user: user, org: org, flag_name: flag_name} do + FunWithFlags.enable(flag_name) + + assert FunWithFlags.enabled?(flag_name, for_hierarchy: [user, org]) + + FunWithFlags.disable(flag_name) + + refute FunWithFlags.enabled?(flag_name, for_hierarchy: [user, org]) + end + + test "it checks group gates when actor gates don't match", %{user: user, org: org, flag_name: flag_name} do + FunWithFlags.disable(flag_name) + FunWithFlags.enable(flag_name, for_group: :wizards) + + assert FunWithFlags.enabled?(flag_name, for_hierarchy: [user, org]) + end + + test "it processes actors in order, checking both actor and group gates", %{user: user, org: org, flag_name: flag_name} do + FunWithFlags.disable(flag_name) + FunWithFlags.enable(flag_name, for_group: :schools) + FunWithFlags.disable(flag_name, for_group: :wizards) + + refute FunWithFlags.enabled?(flag_name, for_hierarchy: [user, org]) + end + + test "empty hierarchy falls back to boolean gate", %{flag_name: flag_name} do + FunWithFlags.enable(flag_name) + + assert FunWithFlags.enabled?(flag_name, for_hierarchy: []) + + FunWithFlags.disable(flag_name) + + refute FunWithFlags.enabled?(flag_name, for_hierarchy: []) + end + + test "with multiple actors, first match wins", %{flag_name: flag_name} do + user1 = %FunWithFlags.TestUser{id: 1, name: "User 1", groups: []} + user2 = %FunWithFlags.TestUser{id: 2, name: "User 2", groups: []} + user3 = %FunWithFlags.TestUser{id: 3, name: "User 3", groups: []} + + FunWithFlags.disable(flag_name) + FunWithFlags.enable(flag_name, for_actor: user2) + FunWithFlags.disable(flag_name, for_actor: user3) + + assert FunWithFlags.enabled?(flag_name, for_hierarchy: [user1, user2, user3]) + end + + test "respects actor gate precedence over group gates within same actor", %{flag_name: flag_name} do + user = %FunWithFlags.TestUser{id: 1, name: "User", groups: [:group1]} + org = %FunWithFlags.TestOrg{id: 100, name: "Org", groups: [:group2]} + + FunWithFlags.disable(flag_name) + FunWithFlags.disable(flag_name, for_group: :group1) + FunWithFlags.enable(flag_name, for_actor: user) + FunWithFlags.disable(flag_name, for_group: :group2) + + assert FunWithFlags.enabled?(flag_name, for_hierarchy: [user, org]) + end + + test "works with complex hierarchy and mixed gates", %{flag_name: flag_name} do + user = %FunWithFlags.TestOrg{id: 1, name: "User", groups: [:employees]} + team = %FunWithFlags.TestOrg{id: 10, name: "Team", groups: [:teams]} + division = %FunWithFlags.TestOrg{id: 100, name: "Division", groups: [:divisions]} + company = %FunWithFlags.TestOrg{id: 1000, name: "Company", groups: [:companies]} + + FunWithFlags.disable(flag_name) + FunWithFlags.enable(flag_name, for_group: :companies) + + assert FunWithFlags.enabled?(flag_name, for_hierarchy: [user, team, division, company]) + + FunWithFlags.disable(flag_name, for_group: :divisions) + + refute FunWithFlags.enabled?(flag_name, for_hierarchy: [user, team, division, company]) + + FunWithFlags.enable(flag_name, for_actor: division) + + assert FunWithFlags.enabled?(flag_name, for_hierarchy: [user, team, division, company]) + + FunWithFlags.disable(flag_name, for_actor: team) + + refute FunWithFlags.enabled?(flag_name, for_hierarchy: [user, team, division, company]) + end + end end diff --git a/test/support/test_user.ex b/test/support/test_structs.ex similarity index 54% rename from test/support/test_user.ex rename to test/support/test_structs.ex index 87ecf809..1bfe28e1 100644 --- a/test/support/test_user.ex +++ b/test/support/test_structs.ex @@ -3,13 +3,17 @@ defmodule FunWithFlags.TestUser do defstruct [:id, :email, :name, groups: []] end +defmodule FunWithFlags.TestOrg do + # A Test organization + defstruct [:id, :name, groups: []] +end + defimpl FunWithFlags.Actor, for: FunWithFlags.TestUser do def id(%{id: id}) do "user:#{id}" end end - defimpl FunWithFlags.Group, for: FunWithFlags.TestUser do def in?(%{email: email}, "admin") do Regex.match?(~r/@wayne.com$/, email) @@ -26,3 +30,22 @@ defimpl FunWithFlags.Group, for: FunWithFlags.TestUser do Enum.any? groups, fn(g) -> to_string(g) == group_s end end end + +defimpl FunWithFlags.Actor, for: FunWithFlags.TestOrg do + def id(%{id: id}) do + "org:#{id}" + end +end + +defimpl FunWithFlags.Group, for: FunWithFlags.TestOrg do + def in?(%{groups: groups}, group) do + group_s = to_string(group) + Enum.any? groups, fn(g) -> to_string(g) == group_s end + end + + def in?(org, group) when is_atom(group) do + __MODULE__.in?(org, to_string(group)) + end + + def in?(_, _), do: false +end