How can I define a simple hierarchical access control system in Haskell?
My roles are Public > Contributor > Owner
, these roles are in a hierarchy. Everything that can be done by Public
can also be done by Contributor
and Owner
; and so on.
Similarly Operations are also in a hierarchy: None > View > Edit
. If a Role is permitted to Edit, it should also be able to View.
data Role = Public | Contributor | Owner
data Operation = None | View | Edit
newtype Policy = Policy (Role -> Operation)
In this system I can express public editable policy as:
publicEditable :: Policy
publicEditable = Policy $ const Edit
But type system does not prevent me from defining stupid policies like this (that permits Public
to Edit
but denies any access to the Owner
):
stupidPolicy :: Policy
stupidPolicy = Policy check where
check Public = Edit
check Contributor = View
check Owner = None
How can I express the hierarchical nature of Role and Operation in the type system?
Anyone with access to Policy
's constructors can take a Policy
apart and put it back together, possibly in a nonsensical fashion. Don't expose the Policy
constructor outside of this module. Instead, provide a smart constructor to create policies that are guaranteed to be well-formed and expose a Monoid
interface to compose them without breaking invariants. Keeping the Policy
type abstract ensures that all the code which could result in nonsensical policies is kept inside this module.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Policy (
Role(..),
Level(..),
Policy, -- keep Policy abstract by not exposing the constructor
can
) where
import Data.Semigroup (Semigroup, Max(..))
data Role = Public | Contributor | Owner
deriving (Eq, Ord, Bounded, Enum, Show, Read)
data Level = None | View | Edit
deriving (Eq, Ord, Bounded, Enum, Show, Read)
Below I'm using GeneralizedNewtypeDeriving
to borrow a pair of Monoid
instances from base
: the monoid for functions, which lifts another monoid through the function arrow point-wise, and the Max
newtype, which turns an Ord
instance into a Monoid
instance by always choosing the larger of mappend
's arguments.
So Policy
's Monoid
instance will automatically manage the ordering of Level
when composing policies: when composing two policies with conflicting levels at a given role we'll always choose the more permissive one. This makes <>
an additive operation: you define policies by adding permissions to the "default" policy, mempty
, which is the one which grants no permissions to anyone.
newtype Policy = Policy (Role -> Max Level) deriving (Semigroup, Monoid)
grant
is a smart constructor which produces policies which respect the ordering properties of Role
and Level
. Note that I'm comparing roles with >=
to ensure that granting a permission to a role also grants that permission to more privileged roles.
grant :: Role -> Level -> Policy
grant r l = Policy (Max . pol)
where pol r'
| r' >= r = l
| otherwise = None
can
is an observation which tells you whether a policy grants a given access level to a given role. Once more I'm using >=
to ensure that more-permissive levels imply less-permissive ones.
can :: Role -> Level -> Policy -> Bool
(r `can` l) (Policy f) = getMax (f r) >= l
I was pleasantly surprised by how little code this module took! Leaning on the deriving
mechanism, especially GeneralizedNewtypeDeriving
, is a really nice way of putting the types in charge of "boring" code so you can focus on the important stuff.
Usage of these policies looks like this:
module Client where
import Data.Monoid ((<>))
import Policy
You can use the Monoid
class to build complex policies out of simple ones.
ownerEdit, contributorView, myPolicy :: Policy
ownerEdit = grant Owner Edit
contributorView = grant Contributor View
myPolicy = ownerEdit <> contributorView
And you can use the can
function to test policies.
canPublicView :: Policy -> Bool
canPublicView = Public `can` View
For example:
ghci> canPublicView myPolicy
False
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With