packages feed

shomei-core-0.2.0.0: src/Shomei/Account/Admin/Workflow.hs

-- | The audited administrative lifecycle workflows (EP-2 of MasterPlan 7): suspending,
-- reinstating and deleting a user, and revoking sessions on their behalf.
--
-- Every function takes the acting administrator's 'UserId' first and the target second, and
-- records the actor on the audit event it publishes. That is the whole reason these live in a
-- workflow rather than in the HTTP handlers: an administrative state change that leaves no trace
-- of /who/ made it is not an audit trail.
--
-- __These workflows neither authenticate nor authorize.__ They do not check that the acting user
-- holds the @admin@ role, and they do not refuse a self-targeted suspension. Those are HTTP-layer
-- policy (see @Shomei.Servant.Authz.requireAdmin@ and the handlers' self-target refusal), because
-- a different surface may reasonably decide differently — the @shomei-admin@ CLI, for instance,
-- has no notion of a caller at all.
--
-- Status transitions are strict rather than idempotent: suspending an already-suspended user is
-- an 'InvalidUserStatus' error, not a silent success. Two administrators responding to one
-- incident must be able to tell which of them actually changed the state.
--
-- Deletion is a __soft delete__ ('UserDeleted' status), never a row removal: sessions, role
-- grants, and audit events reference the user row, and the trail must survive the account.
module Shomei.Account.Admin.Workflow
  ( suspendUser,
    reinstateUser,
    deleteUser,
    revokeUserSessions,
    revokeOneSession,
  )
where

import Effectful (Eff, (:>))
import Shomei.Account.User.Domain (User (..), UserStatus (..))
import Shomei.Account.User.Store (UserStore, findUserById, updateUserStatus)
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)
import Shomei.Error (AuthError (..))
import Shomei.Id (SessionId, UserId)
import Shomei.Prelude
import Shomei.Session.Domain (Session (..), SessionStatus (..))
import Shomei.Session.Store
  ( SessionStore,
    findSessionById,
    listSessionsForUser,
    revokeAllUserSessions,
    revokeSession,
  )
import Shomei.Time.Store (Clock, now)

-- | Suspend an active user and kill their sessions.
--
-- Their outstanding /access/ tokens still ride out their short TTL under the default
-- @sessionCheckMode = VerifyTokenOnly@. A deployment that cannot tolerate that window sets
-- @VerifyTokenAndSession@: the HTTP auth handler then re-reads the session on every request
-- through 'Shomei.Session.Authentication.Workflow.verifyToken', and the next request is refused with
-- @401 session_revoked@, at the cost of one session lookup per authenticated request. The refresh
-- path is closed immediately either way, so under the default the blast radius is one access-token
-- lifetime.
suspendUser ::
  (UserStore :> es, SessionStore :> es, AuthEventPublisher :> es, Clock :> es) =>
  -- | the acting administrator
  UserId ->
  -- | the target
  UserId ->
  Eff es (Either AuthError ())
suspendUser actingAdmin target =
  transition target [UserActive] UserSuspended \ts -> do
    revokeAllUserSessions target ts
    publishAuthEvent (Event.UserSuspended (Event.UserSuspendedData target (Just actingAdmin) ts))

-- | Return a suspended user to service. Their sessions stay revoked; they log in again.
reinstateUser ::
  (UserStore :> es, SessionStore :> es, AuthEventPublisher :> es, Clock :> es) =>
  UserId ->
  UserId ->
  Eff es (Either AuthError ())
reinstateUser actingAdmin target =
  transition target [UserSuspended] UserActive \ts ->
    publishAuthEvent (Event.UserReinstated (Event.UserReinstatedData target (Just actingAdmin) ts))

-- | Soft-delete a user and kill their sessions. Reachable from either live status.
deleteUser ::
  (UserStore :> es, SessionStore :> es, AuthEventPublisher :> es, Clock :> es) =>
  UserId ->
  UserId ->
  Eff es (Either AuthError ())
deleteUser actingAdmin target =
  transition target [UserActive, UserSuspended] UserDeleted \ts -> do
    revokeAllUserSessions target ts
    publishAuthEvent (Event.UserDeleted (Event.UserDeletedData target (Just actingAdmin) ts))

-- | Look the target up, check it is in one of @allowed@, move it to @newStatus@, and run
-- @after@. The shared skeleton of the three lifecycle transitions.
transition ::
  (UserStore :> es, Clock :> es) =>
  UserId ->
  [UserStatus] ->
  UserStatus ->
  (UTCTime -> Eff es ()) ->
  Eff es (Either AuthError ())
transition target allowed newStatus after = do
  mUser <- findUserById target
  case mUser of
    Nothing -> pure (Left UserNotFound)
    Just user
      | user.status `notElem` allowed -> pure (Left InvalidUserStatus)
      | otherwise -> do
          ts <- now
          won <- updateUserStatus target allowed newStatus ts
          if won
            then after ts >> pure (Right ())
            else pure (Left InvalidUserStatus)

-- | Revoke every /active/ session of a user, returning how many were revoked.
--
-- Already-revoked and expired sessions are skipped rather than re-revoked, so the count is the
-- number of sessions this call actually ended and the audit trail carries no duplicate
-- revocations for a session that was already dead.
revokeUserSessions ::
  (SessionStore :> es, AuthEventPublisher :> es, Clock :> es) =>
  UserId ->
  UserId ->
  Eff es (Either AuthError Int)
revokeUserSessions actingAdmin target = do
  sessions <- listSessionsForUser target
  ts <- now
  let active = [s | s <- sessions, s.status == SessionActive]
  forM_ active \s -> do
    revokeSession s.sessionId ts
    publishAuthEvent (Event.SessionRevoked (Event.SessionRevokedData s.sessionId (Just actingAdmin) ts))
  pure (Right (length active))

-- | Revoke one session by id, whoever owns it.
revokeOneSession ::
  (SessionStore :> es, AuthEventPublisher :> es, Clock :> es) =>
  UserId ->
  SessionId ->
  Eff es (Either AuthError ())
revokeOneSession actingAdmin sid = do
  mSession <- findSessionById sid
  case mSession of
    Nothing -> pure (Left SessionNotFound)
    Just _ -> do
      ts <- now
      revokeSession sid ts
      publishAuthEvent (Event.SessionRevoked (Event.SessionRevokedData sid (Just actingAdmin) ts))
      pure (Right ())