DEV Community

Gilbert for The Clause

Posted on • Updated on

Write a Role & Permissions System in 14 lines of prolog (Part 2)

Logic programming is something all developers should learn. It isn't hard, and knowing what's possible will positively influence you in choosing better tools, now and the future.

Part 1 showed how to implement a moderator role with hardcoded permissions. In this post we will implement a more scalable system where roles & permissions are properly decoupled.

Fun fact: This will only increase the number of logical lines from 5 to 14 :)

The Scenario

In part 1 we defined user and club predicates. This isn't actually necessary for our purposes; as long as the ids (e.g. alice and chess) match across predicates, we don't need to define user(alice) and club(chess).

With that in mind, let's build our program from scratch again, and start with defining club memberships:

member(alice, boxing).
member(bob,   boxing).
member(carly, boxing).
member(dan,   boxing).

member(alice, chess).
member(bob,   chess).
Enter fullscreen mode Exit fullscreen mode

Next, let's define some roles. Last time we defined a moderator predicate. This time let's make things more flexible by defining a more general role predicate instead:

role(alice, boxing, admin).
role(bob,   boxing, moderator).
role(bob,   chess,  moderator).
Enter fullscreen mode Exit fullscreen mode

For every moderator permission, we want admins to also have that permission. Let's define this relationship using another predicate:

role_inherits(admin, moderator).
Enter fullscreen mode Exit fullscreen mode

Note that this doesn't actually define any inheritance logic; it only defines a relationship that we will take advantage of later.

Lastly, let's assign specific permissions to specific roles:

permission(admin,     promote_to_mod).
permission(moderator, ban_user).
permission(moderator, ban_protection).
Enter fullscreen mode Exit fullscreen mode

Note 1: Notice how we don't say that admins can ban a user. We are assuming that admins can do everything moderators can do – even though that isn't true yet! The logic for that will come later.

Note 2: Notice how these are all facts. Any facts that you define in Prolog can easily be stored and loaded by an external database.

Query Examples

With our fresh new list of facts, let's run some queries to get a feel for what's possible.

First: What roles does bob have, and in which clubs?

?- role(bob, C, R).
C = boxing,
R = moderator ;
C = chess,
R = moderator.
Enter fullscreen mode Exit fullscreen mode

What permitted actions does bob have in the boxing club?

?- role(bob, boxing, R), permission(R, A).
R = moderator,
A = ban_user ;
R = moderator,
A = ban_protection ;
false.
Enter fullscreen mode Exit fullscreen mode

What permitted actions does alice have in the boxing club?

?- role(alice, boxing, R), permission(R, A).
R = admin,
A = promote_to_mod.
Enter fullscreen mode Exit fullscreen mode

Uh oh. Notice how alice does not have moderator permissions. She only has admin permissions!

Why is this? Let's look at how the permission predicate behaves:

?- permission(moderator, A).
A = ban_user ;
A = ban_protection.

?- permission(admin, A).
A = promote_to_mod.
Enter fullscreen mode Exit fullscreen mode

Aha, the permission predicate is only giving us direct relationships! This is actually a good thing. However, logically we want admin to inherit all permissions from moderator, so let's do that next.

Role Inheritance

This problem brings us to our first two lines of logic: a predicate role_has_permission that handles permissions while also considering role inheritance:

role_has_permission(Role, Action) :- permission(Role, Action).
role_has_permission(Role, Action) :-
  role_inherits(Role, Child),
  role_has_permission(Child, Action).
Enter fullscreen mode Exit fullscreen mode

This is a recursive predicate, and it also happens to be a common Prolog pattern. Here's the explanation:

  • [line 1] A Role has permission to do an Action if it is specified by the permission predicate.
  • [line 2] Either that, or a Role has permission to do an Action if:
    • [line 3] The Role inherits from some Child role,
    • [line 4] where that specific Child role has permission to do the Action.

Now we can correctly get all the permitted actions for a given role, and also query them regarding Alice and the boxing club:

?- role_has_permission(moderator, A).
A = ban_user ;
A = ban_protection ;
false.

?- role_has_permission(admin, A).
A = promote_to_mod ;
A = ban_user ;
A = ban_protection ;
false.

?- role(alice, boxing, R), role_has_permission(R, A).
R = admin,
A = promote_to_mod ;
R = admin,
A = ban_user ;
R = admin,
A = ban_protection ;
false.
Enter fullscreen mode Exit fullscreen mode

Perfect! Now we can correctly ask if a user has a specific permission in a specific club. For example, can Alice and Bob promote other users to moderators?

?- role(alice, boxing, R), role_has_permission(R, promote_to_mod).
R = admin ;
false.

?- role(bob, boxing, R), role_has_permission(R, promote_to_mod).
false.
Enter fullscreen mode Exit fullscreen mode

The first query says "yes, alice can promote_to_mod because she has the admin role". The second query says "no, bob cannot" because bob has no role in the boxing club that also has the promote_to_mod permission.

A Quick Abstraction

In Prolog, it's ideal to encode our real-world questions as predicates. The previous query does not conform to this ideal, so let's write a new predicate to keep our code clean:

user_has_permission(User, Club, Action) :-
  role(User, Club, Role),
  role_has_permission(Role, Action).
Enter fullscreen mode Exit fullscreen mode

Note that this is the same logic as the query we just ran. Here's the explanation:

A User has permission to do an Action in a Club if:

  • [line 2] The User has some Role in Club,
  • [line 3] such that Role has permission to do Action.

With these three lines of code, we can now get a yes/no answer to the last question we asked in the previous section:

% Instead of:
%   role(alice, boxing, R), role_has_permission(R, promote_to_mod).
% We can now write:
?- user_has_permission(alice, boxing, promote_to_mod).
true ;
false.

% Instead of:
%   role(bob, boxing, R), role_has_permission(R, promote_to_mod).
% We can now write:
?- user_has_permission(bob, boxing, promote_to_mod).
false.
Enter fullscreen mode Exit fullscreen mode

Much nicer! We will also reuse this predicate shortly.

Adding Ad-hoc Permission Logic

Ok, now let's update ban_user logic from part 1 to use our new, more scalable system, and achieve the advertised 14 lines of logic. After that, we will also write a new promote_to_mod action for good measure.

First, the new ban_user:

ban_user(Actor, Club, Target) :-
  user_has_permission(Actor, Club, ban_user),
  dif(Actor, Target),
  member(Target, Club),
  \+ user_has_permission(Target, Club, ban_protection).
Enter fullscreen mode Exit fullscreen mode

So easy! If you understood part 1, then no further explanation is needed.

Now let's do promote_to_mod:

promote_to_mod(Actor, Club, Target) :-
  user_has_permission(Actor, Club, promote_to_mod),
  member(Target, Club),
  \+ role(Target, Club, _).
Enter fullscreen mode Exit fullscreen mode

The only new-ish part is the last line. Logically speaking, it only passes when Actor does not have a special role. Programmatically speaking, it gets interesting.

Because of the \+ operator (remember it means "not"), Prolog first tries to prove role(Target, Club, _). If Prolog can prove it, then \+ flips it to false. This effectively means "fail if Target has any special role at all in Club", which is exactly what we want.

If Prolog can't prove it, then \+ will flip it to true, causing promote_to_mod to be true as a whole.

In other words, promote_to_mod will only pass if role(Target, Club, _) is not true. The underscore _ in that code means "something, anything, I don't care what it is, as long as something is there".

Note: Negation is a Prolog fundamental. You have to be careful with what you negate. For example, if you write \+ something(X), and something(X) takes a very long time to prove, then your code will be quite inefficient! Don't worry too much though; just like all languages, there are tricks you can do to get around such problems.

DRYing things up

There's a small amount of WETness in our action code: each action verifies if the actor has permission to do that action! This is quite redundant, as this behavior is obviously implied in any action we write.

To remove this redundancy, we will use the built-in call predicate. Here's an example of using it. The following two queries are equivalent:

?- member(alice, C).
C = boxing ;
C = chess.

?- call(member, alice, C).
C = boxing ;
C = chess.
Enter fullscreen mode Exit fullscreen mode

(For functional programmers, this is similar to a higher-order function. For JavaScripters, this is like Function.prototype.call. For lispers, there's even more to get excited about in Prolog 😉).

call is a very useful tool to learn (and use sparingly). It allows us to use an atom as both a value and a predicate. Behold:

can(Actor, Club, Action, Target) :-
  user_has_permission(Actor, Club, Action),
  call(Action, Actor, Club, Target).

%
% New action code!
% Notice how we removed the first line of each predicate.
%
ban_user(Actor, Club, Target) :-
  dif(Actor, Target),
  member(Target, Club),
  \+ user_has_permission(Target, Club, ban_protection).

promote_to_mod(_Actor, Club, Target) :-
  member(Target, Club),
  \+ role(Target, Club, _).
Enter fullscreen mode Exit fullscreen mode

and its usage:

?- can(alice, boxing, ban_user, carly).
true ;
false.

?- can(alice, boxing, ban_user, bob).
false.
Enter fullscreen mode Exit fullscreen mode

Wonderful! Note that for this to work properly, we must switch to using can instead of directly using ban_user or promote_to_mod. If we were using modules, we would only export can/4.

How exactly does this work? As long as you understand how call works, then the code is straightforward. The key here is realizing Action is used as both a value (in user_has_permission()) and a predicate (in call()).

Heavy note: call will run literally anything, and SWI Prolog has the capability to do sensitive things like read and write files. If you're putting this code on the web, BE SURE TO SANITIZE YOUR INPUTS!

Scalability

The reason this system is more scalable is because action predicates are now based solely on permissions and not roles. To see why, take a look at Part 1's ban_user code:

can(User, Club, ban_user, Target) :-
  moderator(User, Club),
  dif(User, Target),
  member(Target, Club),
  \+ moderator(Target, Club).
Enter fullscreen mode Exit fullscreen mode

This code is not scalable; every time we add another role, we may have to update the code.

In contrast, the new version of ban_user relies on the ban_protection permission! Now when we add a new role, we don't have to update its code. If we want a new role to have ban_protection, we simply declare it using the permission predicate.

Conclusion

And there you have it! A beautiful and scalable roles & permissions system in less than 20 lines of Prolog. You can see the full code here.

In those few lines of code, we were able to:

  • Decoupled roles and permissions
  • Implement role inheritance
  • Cleanly encoded real-world questions into individual predicates
  • Remove redundancy using the higher-order predicate call
  • Build it in such a way that is super easy to extend!

The next post in this series will extend our system with error messages, allowing the system to know why a user's action was rejected.

If you like what you see, star our repo, join our chatroom, and support our Patreon! Until next time!

Oldest comments (1)

Collapse
 
ievolved profile image
Shawn Bullock

This is awesome. I just went through that exercise in pure SQL for Postgres. I needed a database-first solution. And I'm building an app that might get an embeddable Prolog rules engine. So this will be valuable.

Thanks so much for sharing