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 :)
In part 1 we defined
club predicates. This isn't actually necessary for our purposes; as long as the ids (e.g.
chess) match across predicates, we don't need to define
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).
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).
For every moderator permission, we want admins to also have that permission. Let's define this relationship using another predicate:
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).
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.
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.
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.
What permitted actions does
alice have in the boxing club?
?- role(alice, boxing, R), permission(R, A). R = admin, A = promote_to_mod.
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.
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.
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).
This is a recursive predicate, and it also happens to be a common Prolog pattern. Here's the explanation:
- [line 1] A
Rolehas permission to do an
Actionif it is specified by the
- [line 2] Either that, or a
Rolehas permission to do an
- [line 3] The
Roleinherits from some
- [line 4] where that specific
Childrole has permission to do the
- [line 3] The
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.
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.
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
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).
Note that this is the same logic as the query we just ran. Here's the explanation:
User has permission to do an
Action in a
- [line 2] The
- [line 3] such that
Rolehas permission to do
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.
Much nicer! We will also reuse this predicate shortly.
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(Actor, Club, Target) :- user_has_permission(Actor, Club, ban_user), dif(Actor, Target), member(Target, Club), \+ user_has_permission(Target, Club, ban_protection).
So easy! If you understood part 1, then no further explanation is needed.
Now let's do
promote_to_mod(Actor, Club, Target) :- user_has_permission(Actor, Club, promote_to_mod), member(Target, Club), \+ role(Target, Club, _).
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.
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.
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, _).
and its usage:
?- can(alice, boxing, ban_user, carly). true ; false. ?- can(alice, boxing, ban_user, bob). false.
Wonderful! Note that for this to work properly, we must switch to using
can instead of directly using
promote_to_mod. If we were using modules, we would only export
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 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!
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
can(User, Club, ban_user, Target) :- moderator(User, Club), dif(User, Target), member(Target, Club), \+ moderator(Target, Club).
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
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
- 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.