Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 25 additions & 3 deletions lib/fun_with_flags.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 \\ [])
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions lib/fun_with_flags/flag.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
111 changes: 111 additions & 0 deletions test/fun_with_flags_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 24 additions & 1 deletion test/support/test_user.ex → test/support/test_structs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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