Abstracting permissions with Yesod

Yesod is a terrific framework for web applications in Haskell.  It has many, many built-in features.  One of them is that there’s nice support for authentication and authorization.  In this post I’m interested in talking about how you could write your authorization code such that it’s harder to make mistakes.

As shown on the recent example of creating a blog web app, Yesod’s approach to authorization lies within the isAuthorized function:

class ... => Yesod a where
  isAuthorized :: Route a -> Bool -> GHandler s a AuthResult

So isAuthorized takes a Route a, such as EntryR 10, and a Bool telling if the request may do writes (e.g. POST or PUT) or not (e.g. GET or HEAD). It must return an AuthResult that decides if the user is Authorized, Unauthorized or if he needs to be authenticated first (AuthenticationRequired).

While keeping authorization code in a single place is nice, using isAuthorized alone makes it very difficult to test your authorization code. Using the blog example again, only the admin should be able to create blog posts. So let’s take a look at the example code that decides if the user should be authorized to post to the blog:

isAuthorized BlogR True = do
  mauth <- maybeAuth
  case mauth of
    Nothing -> return AuthenticationRequired
    Just (_, user)
      | isAdmin user -> return Authorized
      | otherwise    -> unauthorizedI MsgNotAnAdmin

There are many things going on here:

  1. There’s authentication code that checks if the current user is logged in via maybeAuth.
  2. If the user is not logged in, it returns AuthenticationRequired.
  3. Otherwise the user’s credentials are checked to see if he should be authorized or not.

Step 1 is pretty standard housekeeping-style code. Steps 2 and 3, however, are part of your business logic that decides who should be able to do what. This means that you should be able to create (a) an unit test that asserts that the admin can create blog posts and (b) an unit test that asserts that a non-admin can’t create blog posts. Unfortunately, with the code above writing unit tests is really difficult. You not only need to artificially run the GHandler monad (boring), but you need to fake the current session so that Step 1’s maybeAuth gets the right information (difficult). Even then, your isAuthorized function is allowed to do anything, since it’s inside GHandler.

Enter permissions. What we’re really trying to say in the code above is that (a) to create a blog post you need the “post” permission and (b) admin has “post” permission, non-admins don’t. So let’s split these things! First of all, we need a list of the permissions that we’ll need:

data Permission = Post | Comment

You should read these constructors names with “permission to” before their names. For example, the admin has permission to Post and any logged user has permission to Comment.

Each request needs to decide which permissions it requires. This is one of the most important pieces of your application’s security, since forgetting to ask for permissions could lead to catastrophic problems. Instead of having this core piece of your app diluted in isAuthorized, we use a simple, clear, pure function called permissionsRequiredFor. The idea is to make permissionsRequiredFor as simple as possible, such that with code review alone you could determine if you’re asking for the right permissions.

permissionsRequiredFor :: Route Blog -> Bool -> [Permission]
permissionsRequiredFor BlogR      True = [Post]
permissionsRequiredFor (EntryR _) True = [Comment]
permissionsRequiredFor _          _    = []

We also need to decide if the currently logged user has the necessary permissions or not. This is the other important piece of your authorization puzzle, and a piece that we need to make easily testable. In order to do so, we avoid maybeAuth and take the user as an argument.

hasPermissionTo :: (UserId, User)
                -> Permission
                -> YesodDB sub Blog AuthResult
(_, user) `hasPermissionTo` Post
  | isAdmin user = return Authorized
  | otherwise    = lift $ unauthorizedI MsgNotAnAdmin
_ `hasPermissionTo` Comment = return Authorized

isAuthorizedTo :: Maybe (UserId, User)
               -> [Permission]
               -> YesodDB sub Blog AuthResult
_       `isAuthorizedTo` []     = return Authorized
Nothing `isAuthorizedTo` (_:_)  = return AuthenticationRequired
Just u  `isAuthorizedTo` (p:ps) = do
  r <- u `hasPermissionTo` p
  case r of
    Authorized -> Just u `isAuthorizedTo` ps
    _          -> return r -- unauthorized

The hasPermissionTo function decides if the user has a given permission or not. While in this example hasPermissionTo could have been a pure function, in general you may need to access the database. The isAuthorizedTo function then (a) decides if the user needs to be authenticated and (b) checks all permissions required from the list using hasPermissionTo.

Finally, we need to implement isAuthorized gluing everything together:

isAuthorized route isWrite = do
  mauth <- maybeAuth
  runDB $ mauth `isAuthorizedTo` permissionsRequiredFor route isWrite

Ok, so what did we gain by writing three more functions?

  • You can easily review permissionsRequiredFor to see if you didn’t leave a restricted route in the open.
  • If you don’t use the wildcards on the last line of permissionsRequiredFor and instead list all of your routes one by one, then you’d get a compiler warning and a runtime error every time you forgot to set the permissions of a newly added route.
  • If you have many routes that needed the same permissions, you don’t need to recode the permission code everywhere. You just need to code it once on hasPermissionTo and then ask for that permission on each of your routes. In my experience, the set of permissions (i.e. the Permission data type) is a lot smaller than the set of possible routes.
  • You may easily create unit tests for hasPermissionTo, increasing your confidence on your code’s correctness.

I should also note that this approach is easily extendable. For example, suppose that you wanted to restrict the visibility of some of your blog posts. You could change the Permission data type into:

data Permission = Post | CommentOn EntryId | View EntryId

Now your hasPermissionTo function is able to give a different answer depending on which blog post we’re talking about.

So far this approach has been successfully used on my day job’s Yesod application. It looks like a cousin of Yesod’s i18n support using data types.

Thanks for reading along this far! Please use the comment section below to say what think of this approach. =)

5 thoughts on “Abstracting permissions with Yesod”

  1. Hi ,
    Thank you for the helpful article. Seems like this is one of the lonely examples on the permissions for Yesod. However this does not compile on the new versions of Yesod. Do you have some update on this ? I got it to compile but I changed the behavior slightly , main problem is that now maybeAuth returns (Maybe (Entity val)) and I don’t know how to go from that to (UserId, User)

  2. Ponadto, że jest rewelacyjnie to można coś przy tym zapisać. Korporacje mają zawsze pomysły na co przydzielić finansowania. Wobec tego proponuje zobacz to https://finanserek.pl/ . Takie sponsorowanie dla kompanii to jest problem przez cały czas aktualny. Potrzeb jest wiele i pomysłów na spożytkowanie pieniędzy także.

Leave a Reply

Your email address will not be published. Required fields are marked *