LetMe - authorization DSL

The examples only use RBAC for illustration, but LetMe does not make any assumption about the kind of checks you run. If you need to make a decision based on one or multiple claims, you can just write checks for those claims. It also does not make any assumptions about the format of the subject or the object, which means you can also pass a tuple or a map with claims or anything else as a subject, and an object or multiple objects or more data relating to the object in any format you need, as long as your check functions understand them. You can also register pre-hooks to run before running the checks, e.g. to load more data necessary in multiple checks (LetMe.Policy — LetMe v3.0.1).

So in your example, assuming that there are multiple bars, the user can have different customer statuses in each bar, and you already loaded the location, drink, and customer status for the specific bar before running the permission checks, you could end up with a policy module like this:

defmodule MyApp.Policy do
    use LetMe.Policy

    object :drink do
      action :consume do
        allow storage: :counter
        allow customer_status: :vip, storage: :cellar

        # deny access to alcoholic drinks if user is below drinking age, no matter what
        deny [:drink_is_alcoholic, :below_drinking_age]

        # deny access to any banned user
        deny customer_status: :banned
      end
    end
  end

With these check functions:

  defmodule MyApp.Policy.Checks do
    def customer_status(%User{}, %{customer_statuses: statuses}, status) do
      Enum.find_value(statuses, false, &(&1.type == status))
    end

    def storage(_, %{drink: %Drink{storage: storage}}, storage), do: true
    def storage(_, %{drink: %Drink{}}, _), do: false

    def drink_is_alcoholic(_, %{drink: %Drink{alcoholic: alcoholic}}),
      do: alcoholic

    def below_drinking_age(%User{} = user, %{location: %Location{} = location}) do
      age = SomeModule.get_age(user.birthdate)
      legal_drinking_age = SomeModule.get_legal_drinking_age(location.country)
      age < legal_drinking_age
    end
  end

With this, you pass all the necessary information to the authorize function:

  user = %User{id: 10, birthdate: ~D[2002-02-05]}

  object = %{
    # storage can be :counter, :cellar, :private_lounge
    drink: %Drink{alcoholic: true, storage: :counter},
    location: %Location{id: 20, country: "uk"},
    customer_statuses: [
      # type can be :vip, :banned, :private_lounge_access
      %CustomerStatus{user_id: 10, location_id: 20, type: :vip}
    ]
  }

  Policy.authorize?(:drink_consume, user, object)

Alternatively, you could only pass the drink and the location, and preload the customer statuses with a pre-hook. Or maybe the location is preloaded in the drink struct. Whatever it is, LetMe doesn’t care about these details. It’s up to you!

In general, I would opt for parameterized rules if possible, as opposed to very specific rules (e.g. allow customer_status: :vip instead of allow :is_vip). Personally, I wouldn’t go too far with abstractions in favor of readability, but if you wanted, you could define more general check functions, e.g. one that checks for equality of a value in any nested map: allow match: {[:path, :to, :value], :value_to_check}.

This doesn’t cover every single rule you listed, but should be enough to illustrate how you can compose complex rule sets.

2 Likes