Hi folks. I put together a new library that adds a an Auth Role combinator to servant:
It allows you to gate access to routes by custom auth role rules and propagates GDP style proofs into the handlers to gate subroutine calls.
I’m not using in production yet but would love to get feedback from the community. I’ll probably start using it for my radio station web site (www.kpbj.fm) soon as a test case.
Here is an example, taken from the readme, of what it looks like:
-- default-language: GHC2021
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module Main where
import Servant.Auth.Roles.TH
import Control.Monad.Except (throwError)
import Network.Wai (Request, requestHeaders)
import Network.Wai.Handler.Warp (run)
import Servant.API (Get, JSON, type (:<|>) (..), type (:>))
import Servant.API.Experimental.Auth (AuthProtect)
import Servant.Server (Context (..), Handler, Server, err401, serveWithContext)
import Servant.Server.Experimental.Auth (AuthHandler, AuthServerData, mkAuthHandler)
-- 1. A plain role enum. No deriving clause needed.
data UserRole = Viewer | Editor | Admin
-- 2. One splice. Constructor order defines the hierarchy: Admin > Editor > Viewer.
$(deriveOrdRole ''UserRole)
-- 3. Your auth type, indexed by the role.
newtype RoleAuth (r :: UserRole) = RoleAuth {rName :: String}
-- 4. Declare your @AuthServerData@ intance with the provided @SomeRole@ existential wrapper.
type instance AuthServerData (AuthProtect "role-auth") = SomeRole RoleAuth
-- 5. Define a standard @AuthHandler@, using @someUserRole@ to construct your @SomeRole@ value.
authHandler :: AuthHandler Request (SomeRole RoleAuth)
authHandler = mkAuthHandler $ \req ->
case lookup "X-Role" (requestHeaders req) of
Just "viewer" -> pure (someUserRole Viewer (RoleAuth "Reed"))
Just "editor" -> pure (someUserRole Editor (RoleAuth "Lyxia"))
Just "admin" -> pure (someUserRole Admin (RoleAuth "Sandy"))
_ -> throwError err401
-- 6. Gate the routes.
type API =
RequireRole "role-auth" 'Viewer :> "viewer" :> Get '[JSON] String
:<|> RequireRole "role-auth" 'Admin :> "admin" :> Get '[JSON] String
-- A statically admin-gated subroutine. `IsAtleastAdmin` is generated by the
-- splice.
banUser :: (IsAtleastAdmin r) => RoleAuth r -> String
banUser auth = "banned by " <> rName auth
server :: Server API
server = viewerH :<|> adminH
where
viewerH :: Satisfies 'Viewer RoleAuth -> Handler String
viewerH (Satisfies _ auth) = pure ("viewer: " <> rName auth)
-- Matching on the proof constructor brings the `'Admin <= r` evidence into
-- scope, which is what lets `banUser` be called here and nowhere else.
adminH :: Satisfies 'Admin RoleAuth -> Handler String
adminH (Satisfies UserRoleProof auth) = pure (banUser auth)
main :: IO ()
main = run 3000 $ serveWithContext (Proxy @API) (authHandler :. EmptyContext) server