Modern complex projects often require sophisticated access-control functionality, like Identity and Access Management (IAM ) systems. Sometimes, however there are cases where simple solutions might just cut it. Let’s cover one of these cases.
Say we’re working on a system for publishing articles, allowing discussion below each article, with the following requirements:
- Moderators can edit all articles and comments
- Authors can create articles and edit only those created by themselves
- Users can read articles and post comments about them
- Any user-type can edit and delete their own profile
We could define different user-levels as a bit-mask and store that alongside user’s information (e.g. users table in the database):
const (
user = 1 << iota // 0001
author // 0010
moderator // 0100
self // 1000
)
Each of the actions listed above can now be expressed as a list of user types allowed to perform them, by using the bitwise OR operator, omitting actions allowed by everybody even without an account:
const (
CreateArticle = author
UpdateArticle = self | moderator
DeleteArticle = self | moderator
CreateComment = user | author | moderator
UpdateComment = moderator
DeleteComment = moderator
UpdateUser = self
DeleteUser = self
)
Each action has only the bits of every user type set to 1. For instance
CreateArticle is 2 (0010), UpdateArticle is 12 (1100) and CreateComment
is 7 (0111). We could now create a function which determines whether given
action is allowed by simply checking if they share at least one bit set to 1,
for which we’re going to utilize the bitwise
AND
operator.
self is a bit tricky here, because it must be populated differently per-entity. So we should make sure that both users, articles and comments have ID:
type User struct {
ID ID
Role int
Name string
// ...
}
type Article struct {
ID ID
Author ID
Name string
// ...
}
type Comment struct {
ID ID
Author ID
Article ID
// ...
}
The ID type here can be anything comparable (e.g. int, string, UUID).
Now the function in question is painfully obvious:
func Allowed(u User, action int, target ID) bool {
if user.ID == target {
u.Role |= self
}
return u.Role&action != 0
}
target is the ID of whatever is being acted upon or left with the zero value
for that type when not applicable (e.g. on any Create… action). In order to
check whether a user (u) can update given article (a) we call
Allowed(u, UpdateArticle, a.Author) and handle (e.g. return error) when the
result is false.
However…
You’ve probably already figured out that this mimics the way Unix operating systems encode filesystem access. I could think of a few pros to this approach:
- The check whether given action is allowed is trivial
- Adding new user type is a single line of code. Each new user adds exactly one bit to the mask, so until we go above 64, everything’s fine
- Permissions are obvious even to non-technical people
- Same is applicable to pretty much any programming language I can think of
However, this does not come without gotchas. Take that self which is not
really a user type, but a made-up concept to allow everybody to edit some
of their own creations. We had to add it, because it’s not a desired
capability for all entities. Comments for instance can NOT be edited, once
posted. Not only that, we had to add a new argument and logic to our little
pure function.
One could think of a few new requirements that would seem simple, yet require more arguments, more checks and eventually a complete overhaul of this whole idea. Technically speaking, even the Unix permissions turned out to be not enough and modern Unix-like operating systems implement more complex ACLs stored in the extended file attributes on top of it.