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...