feat(ldap): automatically synchronise user data from ldap

This commit is contained in:
Gregor Kleen 2019-08-29 15:03:33 +02:00
parent 7d927fdd5f
commit b39ba8b268
11 changed files with 144 additions and 12 deletions

View File

@ -41,6 +41,9 @@ health-check-delay-notify: "_env:HEALTHCHECK_DELAY_NOTIFY:true"
health-check-http: "_env:HEALTHCHECK_HTTP:true" # Can we assume, that we can reach ourselves under APPROOT via HTTP (reverse proxies or firewalls might prevent this)? health-check-http: "_env:HEALTHCHECK_HTTP:true" # Can we assume, that we can reach ourselves under APPROOT via HTTP (reverse proxies or firewalls might prevent this)?
health-check-active-job-executors-timeout: "_env:HEALTHCHECK_ACTIVE_JOB_EXECUTORS_TIMEOUT:5" health-check-active-job-executors-timeout: "_env:HEALTHCHECK_ACTIVE_JOB_EXECUTORS_TIMEOUT:5"
synchronise-ldap-users-within: "_env:SYNCHRONISE_LDAP_WITHIN:604800"
synchronise-ldap-users-interval: "_env:SYNCHRONISE_LDAP_INTERVAL:3600"
log-settings: log-settings:
detailed: "_env:DETAILED_LOGGING:false" detailed: "_env:DETAILED_LOGGING:false"
all: "_env:LOG_ALL:false" all: "_env:LOG_ALL:false"

View File

@ -14,6 +14,8 @@ User json -- Each Uni2work user has a corresponding row in this table; create
ident (CI Text) -- Case-insensitive user-identifier ident (CI Text) -- Case-insensitive user-identifier
authentication AuthenticationMode -- 'AuthLDAP' or ('AuthPWHash'+password-hash) authentication AuthenticationMode -- 'AuthLDAP' or ('AuthPWHash'+password-hash)
lastAuthentication UTCTime Maybe -- last login date lastAuthentication UTCTime Maybe -- last login date
created UTCTime default=now()
lastLdapSynchronisation UTCTime Maybe
tokensIssuedAfter UTCTime Maybe -- do not accept bearer tokens issued before this time (accept all tokens if null) tokensIssuedAfter UTCTime Maybe -- do not accept bearer tokens issued before this time (accept all tokens if null)
matrikelnummer UserMatriculation Maybe -- optional immatriculation-string; usually a number, but not always (e.g. lecturers, pupils, guests,...) matrikelnummer UserMatriculation Maybe -- optional immatriculation-string; usually a number, but not always (e.g. lecturers, pupils, guests,...)
firstName Text -- For export in tables, pre-split firstName from displayName firstName Text -- For export in tables, pre-split firstName from displayName

View File

@ -3110,12 +3110,15 @@ upsertCampusUser ldapData Creds{..} = do
, userNotificationSettings = def , userNotificationSettings = def
, userMailLanguages = def , userMailLanguages = def
, userTokensIssuedAfter = Nothing , userTokensIssuedAfter = Nothing
, userCreated = now
, userLastLdapSynchronisation = Just now
, .. , ..
} }
userUpdate = [ UserMatrikelnummer =. userMatrikelnummer userUpdate = [ UserMatrikelnummer =. userMatrikelnummer
, UserDisplayName =. userDisplayName , UserDisplayName =. userDisplayName
, UserSurname =. userSurname , UserSurname =. userSurname
, UserEmail =. userEmail , UserEmail =. userEmail
, UserLastLdapSynchronisation =. Just now
] ++ ] ++
[ UserLastAuthentication =. Just now | not isDummy ] [ UserLastAuthentication =. Just now | not isDummy ]

View File

@ -66,6 +66,7 @@ import Jobs.Handler.SendCourseCommunication
import Jobs.Handler.Invitation import Jobs.Handler.Invitation
import Jobs.Handler.SendPasswordReset import Jobs.Handler.SendPasswordReset
import Jobs.Handler.TransactionLog import Jobs.Handler.TransactionLog
import Jobs.Handler.SynchroniseLdap
import Jobs.HealthReport import Jobs.HealthReport
@ -428,11 +429,19 @@ jLocked jId act = do
pruneLastExecs :: Crontab JobCtl -> DB () pruneLastExecs :: Crontab JobCtl -> DB ()
pruneLastExecs crontab = runConduit $ selectSource [] [] .| C.mapM_ ensureCrontab pruneLastExecs crontab = runConduit $ selectSource [] [] .| C.mapM_ ensureCrontab
where where
ensureCrontab (Entity leId CronLastExec{..}) ensureCrontab (Entity leId CronLastExec{..}) = void . runMaybeT $ do
| Just job <- Aeson.parseMaybe parseJSON cronLastExecJob now <- liftIO getCurrentTime
, HashMap.member (JobCtlQueue job) crontab flushInterval <- MaybeT . getsYesod . view $ appSettings . _appJobFlushInterval
= return ()
| otherwise = delete leId
if
| abs (now `diffUTCTime` cronLastExecTime) > flushInterval * 2
-> return ()
| Just job <- Aeson.parseMaybe parseJSON cronLastExecJob
, HashMap.member (JobCtlQueue job) crontab
-> return ()
| otherwise
-> lift $ delete leId
determineCrontab' :: DB (Crontab JobCtl) determineCrontab' :: DB (Crontab JobCtl)
determineCrontab' = (\ct -> ct <$ pruneLastExecs ct) =<< determineCrontab determineCrontab' = (\ct -> ct <$ pruneLastExecs ct) =<< determineCrontab

View File

@ -11,6 +11,7 @@ import qualified Data.Map as Map
import Data.Semigroup (Max(..)) import Data.Semigroup (Max(..))
import Data.Time.Zones import Data.Time.Zones
import Data.Time.Clock.POSIX
import Control.Monad.Trans.Writer (WriterT, execWriterT) import Control.Monad.Trans.Writer (WriterT, execWriterT)
import Control.Monad.Writer.Class (MonadWriter(..)) import Control.Monad.Writer.Class (MonadWriter(..))
@ -23,7 +24,7 @@ import qualified Database.Esqueleto as E
determineCrontab :: DB (Crontab JobCtl) determineCrontab :: DB (Crontab JobCtl)
-- ^ Extract all future jobs from the database (sheet deadlines, ...) -- ^ Extract all future jobs from the database (sheet deadlines, ...)
determineCrontab = execWriterT $ do determineCrontab = execWriterT $ do
AppSettings{..} <- getsYesod appSettings' UniWorX{ appSettings' = AppSettings{..}, .. } <- getYesod
case appJobFlushInterval of case appJobFlushInterval of
Just interval -> tell $ HashMap.singleton Just interval -> tell $ HashMap.singleton
@ -84,6 +85,48 @@ determineCrontab = execWriterT $ do
, cronNotAfter = Right CronNotScheduled , cronNotAfter = Right CronNotScheduled
} }
if
| is _Just appLdapConf
, is _Just appLdapConf
, Just syncWithin <- appSynchroniseLdapUsersWithin
-> do
now <- liftIO getPOSIXTime
let
interval = appSynchroniseLdapUsersInterval
(ldapEpoch, epochNow) = now `divMod'` syncWithin
ldapInterval = epochNow `div'` interval
numIntervals = floor $ syncWithin / interval
nextIntervals = do
let
n = ceiling $ 4 * appJobCronInterval / appSynchroniseLdapUsersInterval
i <- [negate (ceiling $ n % 2) .. ceiling $ n % 2]
let
((+ ldapEpoch) -> nextEpoch, nextInterval) = (ldapInterval + i) `divMod` numIntervals
nextIntervalTime
= posixSecondsToUTCTime $ fromInteger nextEpoch * syncWithin + fromInteger nextInterval * interval
return (nextEpoch, nextInterval, nextIntervalTime)
forM_ nextIntervals $ \(nextEpoch, nextInterval, nextIntervalTime) -> do
$logDebugS "SynchroniseLdap" [st|currentTime: #{tshow ldapEpoch}.#{tshow epochNow}; upcomingSync: #{tshow nextEpoch}.#{tshow (fromInteger nextInterval * interval)}; upcomingData: #{tshow (numIntervals, nextEpoch, nextInterval)}|]
tell $ HashMap.singleton
(JobCtlQueue JobSynchroniseLdap
{ jEpoch = fromInteger nextEpoch
, jNumIterations = fromInteger numIntervals
, jIteration = fromInteger nextInterval
})
Cron
{ cronInitial = CronTimestamp $ utcToLocalTimeTZ appTZ nextIntervalTime
, cronRepeat = CronRepeatNever
, cronRateLimit = appSynchroniseLdapUsersInterval
, cronNotAfter = Left syncWithin
}
| otherwise
-> return ()
let let
sheetJobs (Entity nSheet Sheet{..}) = do sheetJobs (Entity nSheet Sheet{..}) = do
tell $ HashMap.singleton tell $ HashMap.singleton

View File

@ -0,0 +1,55 @@
module Jobs.Handler.SynchroniseLdap
( dispatchJobSynchroniseLdap
, SynchroniseLdapException(..)
) where
import Import
import qualified Data.Conduit.List as C
import qualified Data.CaseInsensitive as CI
import Auth.LDAP
data SynchroniseLdapException
= SynchroniseLdapNoLdap
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
instance Exception SynchroniseLdapException
dispatchJobSynchroniseLdap :: Natural -> Natural -> Natural -> Handler ()
dispatchJobSynchroniseLdap numIterations epoch iteration = do
UniWorX{ appSettings' = AppSettings{..}, .. } <- getYesod
case (,) <$> appLdapConf <*> appLdapPool of
Just (ldapConf, ldapPool) ->
runDB . runConduit $
readUsers .| filterIteration .| synchroniseUser ldapConf ldapPool
Nothing ->
throwM SynchroniseLdapNoLdap
where
readUsers :: Source (YesodDB UniWorX) UserId
readUsers = selectKeys [] []
filterIteration :: Conduit UserId (YesodDB UniWorX) User
filterIteration = C.mapMaybeM $ \userId -> runMaybeT $ do
let
userIteration, currentIteration :: Integer
userIteration = toInteger (hash epoch `hashWithSalt` userId) `mod` toInteger numIterations
currentIteration = toInteger iteration `mod` toInteger numIterations
$logDebugS "SynchroniseLdap" [st|User ##{tshow (fromSqlKey userId)}: sync on #{tshow userIteration}/#{tshow numIterations}, now #{tshow currentIteration}|]
guard $ userIteration == currentIteration
MaybeT $ get userId
synchroniseUser :: LdapConf -> LdapPool -> Sink User (YesodDB UniWorX) ()
synchroniseUser conf pool = C.mapM_ $ \user -> void . runMaybeT . handleExc $ do
$logInfoS "SynchroniseLdap" [st|Synchronising #{userIdent user}|]
ldapAttrs <- MaybeT $ campusUser' conf pool user
void . lift $ upsertCampusUser ldapAttrs Creds
{ credsIdent = CI.original $ userIdent user
, credsPlugin = "dummy"
, credsExtra = []
}
where
handleExc
= catchMPlus (Proxy @CampusUserException)
. catchMPlus (Proxy @CampusUserConversionException)

View File

@ -80,6 +80,7 @@ writeJobCtlBlock = writeJobCtlBlock' writeJobCtl
queueJobUnsafe :: Bool -> Job -> YesodDB UniWorX QueuedJobId queueJobUnsafe :: Bool -> Job -> YesodDB UniWorX QueuedJobId
queueJobUnsafe queuedJobWriteLastExec job = do queueJobUnsafe queuedJobWriteLastExec job = do
$logInfoS "queueJob" $ tshow job
queuedJobCreationTime <- liftIO getCurrentTime queuedJobCreationTime <- liftIO getCurrentTime
queuedJobCreationInstance <- getsYesod appInstanceID queuedJobCreationInstance <- getsYesod appInstanceID
insert QueuedJob insert QueuedJob

View File

@ -50,6 +50,10 @@ data Job = JobSendNotification { jRecipient :: UserId, jNotification :: Notifica
} }
| JobTruncateTransactionLog | JobTruncateTransactionLog
| JobDeleteTransactionLogIPs | JobDeleteTransactionLogIPs
| JobSynchroniseLdap { jNumIterations
, jEpoch
, jIteration :: Natural
}
deriving (Eq, Ord, Show, Read, Generic, Typeable) deriving (Eq, Ord, Show, Read, Generic, Typeable)
data Notification = NotificationSubmissionRated { nSubmission :: SubmissionId } data Notification = NotificationSubmissionRated { nSubmission :: SubmissionId }
| NotificationSheetActive { nSheet :: SheetId } | NotificationSheetActive { nSheet :: SheetId }

View File

@ -103,7 +103,7 @@ createLdapPool host port stripes timeoutConn (round . (* 1e6) -> timeoutAct) lim
setup <- liftIO newEmptyTMVarIO setup <- liftIO newEmptyTMVarIO
ldapAsync <- allocateAsync . flip runLoggingT logFunc $ do ldapAsync <- allocateAsync . flip runLoggingT logFunc $ do
$logInfoS "LdapExecutor" "Starting" $logDebugS "LdapExecutor" "Starting"
res <- liftIO . Ldap.with host port $ flip runLoggingT logFunc . go (Just setup) res <- liftIO . Ldap.with host port $ flip runLoggingT logFunc . go (Just setup)
case res of case res of
Left exc -> do Left exc -> do

View File

@ -118,6 +118,9 @@ data AppSettings = AppSettings
, appHealthCheckHTTP :: Bool , appHealthCheckHTTP :: Bool
, appHealthCheckActiveJobExecutorsTimeout :: NominalDiffTime , appHealthCheckActiveJobExecutorsTimeout :: NominalDiffTime
, appSynchroniseLdapUsersWithin :: Maybe NominalDiffTime
, appSynchroniseLdapUsersInterval :: NominalDiffTime
, appInitialLogSettings :: LogSettings , appInitialLogSettings :: LogSettings
, appTransactionLogIPRetentionTime :: NominalDiffTime , appTransactionLogIPRetentionTime :: NominalDiffTime
@ -396,6 +399,9 @@ instance FromJSON AppSettings where
appSessionTimeout <- o .: "session-timeout" appSessionTimeout <- o .: "session-timeout"
appSynchroniseLdapUsersWithin <- o .:? "synchronise-ldap-users-within"
appSynchroniseLdapUsersInterval <- o .: "synchronise-ldap-users-interval"
appMaximumContentLength <- o .: "maximum-content-length" appMaximumContentLength <- o .: "maximum-content-length"
appReloadTemplates <- o .:? "reload-templates" .!= defaultDev appReloadTemplates <- o .:? "reload-templates" .!= defaultDev

View File

@ -51,7 +51,7 @@ import Control.Arrow as Utils ((>>>))
import Control.Monad.Trans.Except (ExceptT(..), throwE, runExceptT) import Control.Monad.Trans.Except (ExceptT(..), throwE, runExceptT)
import Control.Monad.Except (MonadError(..)) import Control.Monad.Except (MonadError(..))
import Control.Monad.Trans.Maybe as Utils (MaybeT(..)) import Control.Monad.Trans.Maybe as Utils (MaybeT(..))
import Control.Monad.Catch hiding (throwM) import Control.Monad.Catch (catchIf)
import Language.Haskell.TH import Language.Haskell.TH
import Language.Haskell.TH.Instances () import Language.Haskell.TH.Instances ()
@ -497,6 +497,12 @@ hoistMaybe = maybe mzero return
catchIfMaybeT :: (MonadCatch m, Exception e) => (e -> Bool) -> m a -> MaybeT m a catchIfMaybeT :: (MonadCatch m, Exception e) => (e -> Bool) -> m a -> MaybeT m a
catchIfMaybeT p act = catchIf p (lift act) (const mzero) catchIfMaybeT p act = catchIf p (lift act) (const mzero)
catchMaybeT :: forall p m e a. (MonadCatch m, Exception e) => p e -> m a -> MaybeT m a
catchMaybeT _ act = catch (lift act) (const mzero :: e -> MaybeT m a)
catchMPlus :: forall p m e a. (MonadPlus m, MonadCatch m, Exception e) => p e -> m a -> m a
catchMPlus _ = handle (const mzero :: e -> m a)
mcons :: Maybe a -> [a] -> [a] mcons :: Maybe a -> [a] -> [a]
mcons Nothing xs = xs mcons Nothing xs = xs
mcons (Just x) xs = x:xs mcons (Just x) xs = x:xs