Type Safe Servant Auth Roles

solomonb1 pts0 comments

Servant Auth Roles

SOLOMON'S BLOG

functional programming, permaculture, math

I wanted a nice way to create a roles system on top of<br>servant-auth. This post walks through the design and<br>implementation process. The end result is quite similar to OCharle's Who<br>Authorized These Ghosts. You can see the final result here.

The immediate thing I wanted was auth roles/permission sets in my<br>Servant API types such that I can define distinct handlers for each auth<br>role. I also want to maintain compatibility with<br>AuthProtect.

The idea

My first move was to sketch out an imaginary interface for the<br>library. My hope is that starting from the interface that the code would<br>reveal its implementation to me.

I wanted to define my roles as a sum type and then instantiate a<br>typeclass to describe the role check conditions. This way I could design<br>a variety of role systems (hierarchical, non-hierarchical, set based,<br>etc).

data UserRole = Viewer | Editor | Admin<br>deriving (Eq, Ord, Show)

instance CheckRole 'Viewer where<br>checkRole role = role >= Viewer

instance CheckRole 'Editor where<br>checkRole role = role >= Editor

instance CheckRole 'Admin where<br>checkRole role = role >= Admin

CheckRole allows you to use arbitrary predicates. In<br>this case I use Ord, because I want fall-through but we could just as<br>easily use Eq, or even set membership if you want to model something<br>more like "permissions" then "roles."

Then we would define our typical Servant auth type and<br>AuthServerData instance:

data Authz = Authz { userRole :: UserRole, userName :: String }<br>deriving (Show)

type instance AuthServerData (AuthProtect "test-auth") = Authz

And finally some magical Servant combinator RequireRole<br>I can use in my API types to assign auth roles to routes. The combinator<br>would introduce a role permission check before firing the handler. If<br>the UserRole value from Authz doesn't satisfy<br>the RoleChecK for a handler it fails and tries the next<br>matching route.

type PanelAdminAPI =<br>RequireRole "test-auth" 'Admin<br>:> "panel"<br>:> Get '[JSON] String

type PanelEditorAPI =<br>RequireRole "test-auth" 'Editor<br>:> "panel"<br>:> Get '[JSON] String

type PanelViewerAPI =<br>RequireRole "test-auth" 'Viewer<br>:> "panel"<br>:> Get '[JSON] String

type API = PanelAdminAPI : PanelEditorAPI : PanelViewerAPI

server :: Server API<br>server = panelAdmin : panelEditor : panelViewer<br>where<br>panelAdmin :: Authz -> Handler String<br>panelAdmin _ = pure "admin panel"

panelEditor :: Authz -> Handler String<br>panelEditor _ = pure "editor panel"

panelViewer :: Authz -> Handler String<br>panelViewer _ = pure "viewer panel"

This obviously doens't work as is, but I wanted a DX roughly like<br>this.

Making the idea work

The key to this idea is going to be the RequireRole<br>HasServer instance.

HasServer in a nutshell

But before that, allow me to briefly explain the<br>HasServer class. This isn't a Servant tutorial so I'm going<br>to skip a lot of details.

It has one associated type and two methods:

class HasServer api context where<br>type ServerT api (m :: Type -> Type) :: Type

route :: Proxy api -> Context context -> Delayed env (Server api) -> Router env

hoistServerWithContext<br>:: Proxy api -> Proxy context -> (forall x. m x -> n x) -> ServerT api m -> ServerT api n

type Server api = ServerT api Handler

api is our Servant Combinator. It describes a part of an<br>API's structure. The HasServer instance is sort of a bridge<br>between that API description and an actual route handler that serves<br>it.

The associated type ServerT tells the compiler how to<br>translate from the api type to a fragment of your handler's<br>type signature.

For example the ServerT associated type for<br>Verb is:

type ServerT (Verb method status ctypes a) m = m a

This means Verb 'GET 200 '[JSON] a maps to<br>m a in your handler's signature.

route builds a dispatch tree called a<br>Router that Servant then converts via serve<br>into a WAI Application for processing requests.

Each API component has its own route definition and they<br>recursively build up a Delayed computation used to produce<br>the input to the handler function.

For example, Capture "id" Int says to grab the next URL<br>path segment, try to parse it as an Int, then pass it to<br>the handler. Chaining more API components builds out the<br>Delayed computation whose result is passed to the handler<br>function.

Lastly we have hoistServerWithContext. Servant<br>applications are typically written in a custom monad m but<br>ultimately they need to be run in Handler. We use a natural<br>transformation to map from m to Handler. This<br>function says how to propagate the natural transformation through this<br>API component.

Our instance

Our datatype will contain a symbol representing the auth method to<br>lookup in the Context and a role r that we<br>will use to check against our authenticated user's role:

data RequireRole (tag :: Symbol) (r :: k)

Our hoistServerWithContext definition is boring and just<br>passes the natural transformation through the next API component.

route is where we do all the work. We want to get...

type handler auth role servant instance

Related Articles