packages feed

project-m36-1.2.0: src/lib/ProjectM36/LoginRoles.hs

-- | Provides a simple database mapping login names to role IDs (UUIDs). Only role names which map to a role ID are allowed to log in.
{-# LANGUAGE ScopedTypeVariables, TypeApplications, DeriveGeneric #-}
module ProjectM36.LoginRoles where
import ProjectM36.AccessControlList
import qualified Database.SQLite.Simple as SQL
import Data.UUID (fromString, toText, fromText)
import Data.UUID.V4 (nextRandom)
import Data.Text (Text)
import Data.Maybe
import GHC.Generics
import ProjectM36.Base
import qualified Data.Text as T
import Control.Monad (forM_, void)

type LoginRolesDB = SQL.Connection

type MayLogin = Bool

-- | Used by console programs to change this database of roles which are allowed to login.
data AlterLoginRolesExpr = ShowRolesForRoleExpr RoleName | -- ^ show roles available to the given role
                           AddLoginRoleExpr RoleName MayLogin | -- ^ add a login role, requires admin role
                           RemoveLoginRoleExpr RoleName | -- ^ remove a login role, requires admin role
                           AlterLoginRoleExpr RoleName (Maybe RoleName) (Maybe MayLogin) | -- ^ alter an existing role's name or login right
                           AddRoleToRoleExpr RoleName RoleName MayGrant | -- ^ add a role to an existing role
                           RemoveRoleFromRoleExpr RoleName RoleName | -- ^ remove a role from an existing role
                           AddPermissionToRoleExpr RoleName Permission MayGrant |
                           RemovePermissionFromRoleExpr RoleName Permission |
                           ShowAllRolesExpr -- ^ display all available roles
                           deriving (Eq, Generic, Show)

data LoginRoleError = PermissionDeniedError |
                      NoSuchRoleNameError RoleName |
                      InvalidRoleIdError Text |
                      NoSuchPermissionError Permission |
                      RoleNameAlreadyExistsError RoleName |
                      RoleNameConflictError RoleName
                      deriving (Eq, Generic, Show)

viewLoginRolesPerm :: Permission
viewLoginRolesPerm = "view_login_roles"

alterLoginRolesPerm :: Permission
alterLoginRolesPerm = "alter_login_roles"

openNoPersistence :: IO LoginRolesDB
openNoPersistence = open ":memory:"

open :: String -> IO LoginRolesDB
open openText = do
  db <- SQL.open openText
  setupDatabaseIfNecessary db
  pure db

adminRoleName :: RoleName  
adminRoleName = "admin"

withTransaction :: LoginRolesDB -> IO a -> IO a
withTransaction = SQL.withTransaction

setupDatabaseIfNecessary :: LoginRolesDB -> IO ()
setupDatabaseIfNecessary db = do
  SQL.execute_ db "PRAGMA foreign_keys = ON;"
  SQL.withTransaction db $ do
    res <- SQL.query_ @(SQL.Only Int) db "SELECT 1 FROM sqlite_master WHERE type='table' AND name='login_role_name'"
    case res of
      [SQL.Only _x] -> pure ()
      _ -> do
        let dbsetup =
              ["CREATE TABLE login_role_name(rolename TEXT PRIMARY KEY, roleid TEXT NOT NULL UNIQUE, maylogin BOOLEAN NOT NULL, UNIQUE(rolename), UNIQUE(roleid))",
                "CREATE TABLE permission(name TEXT PRIMARY KEY)",
                "CREATE TABLE login_role_permission(roleid TEXT NOT NULL REFERENCES login_role_name(roleid), permission TEXT NOT NULL REFERENCES permission(name), may_grant BOOLEAN NOT NULL, UNIQUE(roleid, permission, may_grant))",
                "CREATE TABLE login_role_member (roleid TEXT NOT NULL REFERENCES login_role_name(roleid), memberof TEXT NOT NULL, maygrant BOOLEAN NOT NULL, UNIQUE(roleid, memberof))",
              -- create login permissions
              "INSERT INTO permission(name) VALUES ('login'),('alter_login_roles'),('view_login_roles')"]
        forM_ dbsetup $ \sql ->
          SQL.execute_ db sql
        -- add admin role
        SQL.execute db "INSERT INTO login_role_name(rolename, roleid, maylogin) VALUES ('admin',?, true)" (SQL.Only (toText adminRoleId))
        -- grant full permission to admin role
        SQL.execute db "INSERT INTO login_role_permission(roleid, permission,may_grant) VALUES (?,'login',true), (?, 'alter_login_roles',true), (?, 'view_login_roles',true)" (toText adminRoleId, toText adminRoleId, toText adminRoleId)

-- | Determine if the role name has the privilege to login to the database. Some roles are prohibited from logging in.
roleNameMayLogin :: RoleName -> LoginRolesDB -> IO (Either LoginRoleError Bool)
roleNameMayLogin roleName db = do
  res <- SQL.query db "SELECT maylogin FROM login_role_name WHERE rolename = ?" (SQL.Only roleName)
  case res of
    [val] -> pure (Right (SQL.fromOnly val))
    _ -> pure (Left (NoSuchRoleNameError roleName))
  
-- | Test for a role name to role ID mapping. If a role id is found, the role is allowed to login to the database.
roleIdsForRoleName :: RoleName -> LoginRolesDB -> IO (Either LoginRoleError [RoleId])
roleIdsForRoleName roleName db = do
  --with recursive query to extract all roles
  let q = "with recursive cte as (select NULL as roleid,roleid as memberof FROM login_role_name WHERE rolename=? union all select lrm.roleid,lrm.memberof FROM login_role_member as lrm JOIN cte AS c ON c.memberof = lrm.roleid) select memberof from cte;"
  res <- SQL.query db q (SQL.Only roleName)
  pure (Right (map (\x -> case fromString (SQL.fromOnly x) of
                     Nothing -> error "invalid uuid"
                     Just val -> val) res))

roleNamesForRoleName :: RoleName -> LoginRolesDB -> IO (Either LoginRoleError [(RoleName, MayGrant)])
roleNamesForRoleName roleName db = do
  let q = "with recursive cte as (select lrm.roleid,lrm.memberof,lrm.maygrant FROM login_role_member as lrm WHERE roleid=(SELECT roleid FROM login_role_name WHERE rolename=?) union all select lrm.roleid,lrm.memberof,lrm.maygrant FROM login_role_member as lrm JOIN cte AS c ON c.memberof = lrm.roleid) select lrn.rolename,cte.maygrant from cte join login_role_name AS lrn ON lrn.roleid = cte.memberof union all select rolename,false as maygrant FROM login_role_name WHERE rolename=?;"
  eTargetId <- roleIdForRoleName roleName db
  case eTargetId of
    Left err -> pure (Left err)
    Right _ -> do
      roleNamesAndGrants <- SQL.query db q (roleName, roleName)
      pure (Right roleNamesAndGrants)

permissionsForRoleName :: RoleName -> LoginRolesDB -> IO (Either LoginRoleError [Permission])
permissionsForRoleName roleName db = do
  -- collect all roles for the role name
  -- then collect all the permissions for those roles  
  let q = "with recursive cte as (select NULL as roleid,roleid as memberof FROM login_role_name WHERE rolename=? union all select lrm.roleid,lrm.memberof FROM login_role_member as lrm JOIN cte AS c ON c.memberof = lrm.roleid) select permission from login_role_permission WHERE roleid IN (SELECT memberof FROM cte);"
  perms' <- SQL.query db q (SQL.Only roleName)
  pure (Right (map SQL.fromOnly perms'))
  
roleHasPermission :: RoleName -> Permission -> LoginRolesDB -> IO (Either LoginRoleError Bool)
roleHasPermission roleName perm db = do
  eTargetId <- roleIdForRoleName roleName db
  case eTargetId of
    Left err -> pure (Left err)
    Right _ -> do
      let q = "with recursive cte as (select NULL as roleid,roleid as memberof FROM login_role_name WHERE rolename=? union all select lrm.roleid,lrm.memberof FROM login_role_member as lrm JOIN cte AS c ON c.memberof = lrm.roleid),perms AS (select permission from login_role_permission WHERE roleid IN (SELECT memberof FROM cte)) SELECT 1 FROM perms WHERE permission = ?;"
      perms' <- SQL.query @_ @(SQL.Only Int) db q (roleName, perm)
      pure $ case perms' of
        [SQL.Only 1] -> Right True
        _ -> Right False

-- | Add new role name to the database with optional login privilege.
addRoleName :: RoleName -> RoleId -> MayLogin -> LoginRolesDB -> IO (Either LoginRoleError ())
addRoleName roleName newRoleId maylogin db = do
  SQL.execute db "INSERT INTO login_role_name(rolename, roleid, maylogin) VALUES (?,?,?) ON CONFLICT(rolename) DO NOTHING" (roleName, show newRoleId, maylogin)
  c <- SQL.changes db
  if c > 0 then
    pure (Right ())
    else
    pure (Left (RoleNameAlreadyExistsError roleName))

-- Change and existing role's name or login ability.
alterRoleName :: RoleName -> Maybe RoleName -> Maybe MayLogin -> LoginRolesDB -> IO (Either LoginRoleError ())
alterRoleName origRoleName mNewRoleName mMayLogin db = do
  eTargetId <- roleIdForRoleName origRoleName db
  case eTargetId of
      Left err -> pure (Left err)
      Right targetId -> do
        case mNewRoleName of
          Nothing -> pure ()
          Just newRoleName -> 
            void $ SQL.execute db "UPDATE login_role_name SET rolename=? WHERE roleid=?" (newRoleName, toText targetId)
        case mMayLogin of
          Nothing -> pure ()
          Just mayLogin ->
            void $ SQL.execute db "UPDATE login_role_name SET maylogin=? WHERE roleid=?" (mayLogin, toText targetId)
        pure (Right ())

-- | Grant role access to other role.
addRoleToRoleName :: RoleName -> RoleName -> MayGrant -> LoginRolesDB -> IO (Either LoginRoleError ())
addRoleToRoleName roleNameTarget addToRoleName mayGrant db = do
  -- TODO: prevent infinite loops in roles
    eTargetId <- roleIdForRoleName roleNameTarget db
    eAddId <- roleIdForRoleName addToRoleName db
    case eTargetId of
      Left err -> pure (Left err)
      Right targetId ->
        case eAddId of
          Left err -> pure (Left err)
          Right addId -> do
            let q = "INSERT INTO login_role_member(roleid,memberof,maygrant) VALUES (?,?,?)-- ON CONFLICT DO NOTHING"
            SQL.execute db q (toText targetId, toText addId, mayGrant)
            c <- SQL.changes db
            if c == 0 then
              pure (Left (RoleNameConflictError addToRoleName))
              else
              pure (Right ())

removeRoleFromRoleName :: RoleName -> RoleName -> LoginRolesDB -> IO (Either LoginRoleError ())
removeRoleFromRoleName roleNameTarget fromRole db = do
  eTargetId <- roleIdForRoleName roleNameTarget db
  eDelId <- roleIdForRoleName fromRole db
  case eTargetId of
    Left err -> pure (Left err)
    Right targetId ->
      case eDelId of
        Left err -> pure (Left err)
        Right delId -> do
          let q = "DELETE FROM login_role_member WHERE roleid=? AND memberof=?"
          SQL.execute db q (toText targetId, toText delId)
          c <- SQL.changes db 
          if c > 0 then
            pure (Right ())
            else
            pure (Left (NoSuchRoleNameError roleNameTarget))

addPermissionToRoleName :: RoleName -> Permission -> Bool -> LoginRolesDB -> IO (Either LoginRoleError ())
addPermissionToRoleName roleName perm mayGrant db = do
  eTargetId <- roleIdForRoleName roleName db
  case eTargetId of
    Left err -> pure (Left err)
    Right targetId -> do
      let q = "INSERT INTO login_role_permission(roleid,permission,may_grant) VALUES (?,?,?)"
      SQL.execute db q (toText targetId, perm, mayGrant)
      pure (Right ())

-- | Whether or not the granting role has permission to grant the role. To whom is irrelevant.
roleMayGrantRole :: RoleName -> RoleName -> LoginRolesDB -> IO (Either LoginRoleError Bool)
roleMayGrantRole grantingRole roleToGrant db = do
  eroles <- roleNamesForRoleName grantingRole db
  case eroles of
    Left err -> pure (Left err)
    Right roles -> do
      let matches = filter (\(role', maygrant) -> role' == roleToGrant && maygrant) roles
      pure (Right (not (null matches)))

removePermissionFromRoleName :: RoleName -> Permission -> LoginRolesDB -> IO (Either LoginRoleError ())
removePermissionFromRoleName roleName perm db = do
  eTargetId <- roleIdForRoleName roleName db  
  case eTargetId of
    Left err -> pure (Left err)
    Right targetId -> do
      let q = "DELETE FROM login_role_permission WHERE roleid=? AND permission=?"
      SQL.execute db q (toText targetId, perm)
      pure (Right ())
      
-- | Return the primary key for the given role name or Nothing.
roleIdForRoleName :: RoleName -> LoginRolesDB -> IO (Either LoginRoleError RoleId)
roleIdForRoleName rolename db = do
  let q = "SELECT roleid FROM login_role_name where rolename=?"
  res <- SQL.query db q (SQL.Only rolename)
  case res of
   [SQL.Only roleId] ->
     case fromText roleId of
       Nothing -> pure (Left (InvalidRoleIdError roleId))
       Just roleuuid -> pure (Right roleuuid)
   _ -> pure (Left (NoSuchRoleNameError rolename))

-- | Remove a role. Returns True if the role was present and was removed.
removeRole :: RoleName -> LoginRolesDB -> IO (Either LoginRoleError ())
removeRole rolename db = do
  SQL.execute db "DELETE FROM login_role_name WHERE rolename = ?" (SQL.Only rolename)
  c <- SQL.changes db
  if c > 0 then
    pure (Right ())
    else
    pure (Left (NoSuchRoleNameError rolename))

allRoles :: LoginRolesDB -> IO [(RoleName, (RoleId, MayLogin))]
allRoles db = do
  res <- SQL.query_ db "SELECT rolename,roleid, maylogin from login_role_name"
  pure (map (\(rn, rid, maylogin) ->
               (rn,
                (fromMaybe (error "invalid uuid in login_role_name") (fromString rid),
                maylogin))) res)

close :: LoginRolesDB -> IO ()
close = SQL.close

data SuccessResult = QuietSuccessResult |
                     InfoResult T.Text
                   deriving (Eq, Show, Generic)

executeAlterLoginRolesExpr :: RoleName -> LoginRolesDB -> AlterLoginRolesExpr -> IO (Either LoginRoleError SuccessResult)
executeAlterLoginRolesExpr currentRole db expr = do
  let ok = pure (Right QuietSuccessResult)
      checkPerm expr' =
        case expr' of
          ShowRolesForRoleExpr roleName ->
            if currentRole == roleName then
              pure (Right True)
            else
              hasPerm viewLoginRolesPerm
          AddLoginRoleExpr{} -> hasPerm alterLoginRolesPerm
          RemoveLoginRoleExpr{} -> hasPerm alterLoginRolesPerm
          AddRoleToRoleExpr _target other _mayGrant -> do
            eperm <- hasPerm alterLoginRolesPerm
            case eperm of
              Left err -> pure (Left err)
              Right True -> pure (Right True)
              Right False ->
                roleMayGrantRole currentRole other db
          AlterLoginRoleExpr _target _mNewName _mMayLogin -> 
            hasPerm alterLoginRolesPerm
          RemoveRoleFromRoleExpr _target other ->
            roleMayGrantRole currentRole other db
          AddPermissionToRoleExpr{} -> hasPerm alterLoginRolesPerm
          RemovePermissionFromRoleExpr{} -> hasPerm alterLoginRolesPerm
          ShowAllRolesExpr{} -> hasPerm viewLoginRolesPerm
      hasPerm perm = do
        roleHasPermission currentRole perm db

  SQL.withTransaction db $ do
    check <- checkPerm expr
    case check of
      Left err -> pure (Left err)
      Right False -> pure (Left PermissionDeniedError)
      Right True -> do
        case expr of
          ShowRolesForRoleExpr roleName -> do
            eRoles <- roleNamesForRoleName roleName db
            case eRoles of
              Left err ->
                pure (Left err)
              Right roles ->
                pure (Right (InfoResult (T.intercalate "\n" (map (\(r,g) -> r <> if g then ":maygrant" else "") roles))))
          AddLoginRoleExpr roleName maylogin -> do
            newRoleId <- nextRandom
            result <- addRoleName roleName newRoleId maylogin db
            case result of
              Left err -> pure (Left err)
              Right () -> ok
          ShowAllRolesExpr -> do
            roleInfos <- allRoles db
            pure (Right (InfoResult (T.intercalate "\n" (map (\(roleName, (roleId, maylogin)) -> T.pack (show roleId) <> ":" <> roleName <> ":" <> if maylogin then "maylogin" else "maynotlogin") roleInfos))))
          RemoveLoginRoleExpr roleName -> do
            result <- removeRole roleName db
            case result of
              Left err -> pure (Left err)
              Right () -> ok
          AddRoleToRoleExpr targetRole otherRole mayGrant -> do
            -- who can do this? the current user granting his permission or alter_login_roles perm
            res <- addRoleToRoleName targetRole otherRole mayGrant db
            case res of
              Left err -> pure (Left err)
              Right () -> ok
          AlterLoginRoleExpr targetRole mNewRoleName mMayLogin -> do
            res <- alterRoleName targetRole mNewRoleName mMayLogin db
            case res of
              Left err -> pure (Left err)
              Right () -> ok
          RemoveRoleFromRoleExpr targetRole otherRole -> do
            res <- removeRoleFromRoleName targetRole otherRole db
            case res of
              Left err -> pure (Left err)
              Right () -> ok
          AddPermissionToRoleExpr targetRole perm mayGrant -> do
            res <- addPermissionToRoleName targetRole perm mayGrant db
            case res of
              Left err -> pure (Left err)
              Right () -> ok
          RemovePermissionFromRoleExpr targetRole perm -> do
            res <- removePermissionFromRoleName targetRole perm db
            case res of
              Left err -> pure (Left err)
              Right () -> ok