packages feed

shomei-server-0.3.0.0: app/Shomei/Admin/Roles.hs

-- | @shomei-admin roles@ (MasterPlan 7 EP-1, extended by EP-9): declare roles, wire permissions
-- onto them, and grant them (optionally for a bounded window) to users — all without the HTTP
-- server running. This is the bootstrap path for the very first administrator: the @\/admin@
-- surface is gated on the @admin@ role, so something outside HTTP has to grant it.
--
-- @
-- shomei-admin roles define       \<NAME\> [--description TEXT]
-- shomei-admin roles list-defined
-- shomei-admin roles allow        \<ROLE\> \<PERMISSION\>
-- shomei-admin roles disallow     \<ROLE\> \<PERMISSION\>
-- shomei-admin roles show         \<ROLE\>
-- shomei-admin roles grant  --user \<user_… | UUID\> --role NAME [--expires-in \<dur\> | --expires-at \<ISO8601\>]
-- shomei-admin roles revoke --user \<user_… | UUID\> --role NAME
-- shomei-admin roles list   --user \<user_… | UUID\>
-- @
--
-- Granting a role that is not in the registry fails loudly (exit 1) rather than minting a role
-- no gate will ever check — that is what @roles define@ exists for. Likewise @allow@ refuses a
-- permission on an undefined role.
--
-- @define@, @list-defined@, @allow@, @disallow@, and @show@ talk to the
-- 'Shomei.Authorization.Role.Store.RoleStore' port directly: catalog metadata (including permission
-- wiring) is not a security event, so they publish no audit event. @grant@ and @revoke@ go
-- through 'Shomei.Authorization.Role.Workflow', which audits them as @role_granted@ / @role_revoked@ with a
-- @NULL@ actor (there is no authenticated admin on the box); a time-bound grant records its
-- expiry in the @role_granted@ payload.
module Shomei.Admin.Roles
  ( RolesCommand (..),
    GrantExpiry (..),
    rolesParser,
    runRoles,
  )
where

import Control.Monad (forM_)
import Data.Char (isDigit, isSpace)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime)
import Data.Time.Format.ISO8601 (iso8601ParseM, iso8601Show)
import Data.UUID qualified as UUID
import Effectful (Eff, IOE, runEff, (:>))
import Effectful.Error.Static (Error, runErrorNoCallStack)
import Options.Applicative
import Shomei.Account.User.Postgres (runUserStorePostgres)
import Shomei.Account.User.Store (UserStore)
import Shomei.Admin.Env (AdminEnv (..))
import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)
import Shomei.Audit.Publisher.Store (AuthEventPublisher)
import Shomei.Authorization.Claims.Domain (Permission (..), Role (..))
import Shomei.Authorization.Role.Postgres (runRoleStorePostgres)
import Shomei.Authorization.Role.Store
  ( RoleDefinition (..),
    RoleStore,
    allowPermission,
    defineRole,
    disallowPermission,
    listDefinedRoles,
    listPermissionsForRole,
  )
import Shomei.Authorization.Role.Workflow (grantRoleTo, revokeRoleFrom, rolesOf)
import Shomei.Error (AuthError (..))
import Shomei.Id (UserId, idText, parseId, userIdFromUUID)
import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)
import Shomei.Persistence.Pool.Postgres (Pool)
import Shomei.Time.Postgres (runClockIO)
import Shomei.Time.Store (Clock, now)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)

-- The command tree -----------------------------------------------------------

-- | How a grant's expiry was specified on the command line, resolved to an absolute instant at
-- run time (see 'resolveExpiry'). The two forms are mutually exclusive.
data GrantExpiry
  = -- | @--expires-in \<n\>(s|m|h|d)@: relative to the CLI's clock at run time.
    ExpiresIn NominalDiffTime
  | -- | @--expires-at \<ISO8601 UTC\>@: an absolute instant.
    ExpiresAt UTCTime
  deriving stock (Eq, Show)

data RolesCommand
  = RolesDefine Text (Maybe Text)
  | RolesListDefined
  | RolesAllow Text Text
  | RolesDisallow Text Text
  | RolesShow Text
  | RolesGrant Text Text (Maybe GrantExpiry)
  | RolesRevoke Text Text
  | RolesList Text

rolesParser :: Parser RolesCommand
rolesParser =
  hsubparser
    ( command "define" (info defineOpts (progDesc "Declare a role so it can be granted"))
        <> command "list-defined" (info (pure RolesListDefined) (progDesc "Show the role registry"))
        <> command "allow" (info allowOpts (progDesc "Attach a permission to a role"))
        <> command "disallow" (info disallowOpts (progDesc "Detach a permission from a role"))
        <> command "show" (info showOpts (progDesc "Show a role and the permissions it implies"))
        <> command "grant" (info (RolesGrant <$> userOpt <*> roleOpt <*> optional expiryOpt) (progDesc "Grant a defined role to a user"))
        <> command "revoke" (info (RolesRevoke <$> userOpt <*> roleOpt) (progDesc "Revoke a role from a user"))
        <> command "list" (info (RolesList <$> userOpt) (progDesc "Show the roles granted to a user"))
    )
  where
    defineOpts =
      RolesDefine
        <$> (Text.pack <$> argument str (metavar "NAME"))
        <*> optional (Text.pack <$> strOption (long "description" <> metavar "TEXT" <> help "What the role is for"))
    allowOpts = RolesAllow <$> roleArg <*> permissionArg
    disallowOpts = RolesDisallow <$> roleArg <*> permissionArg
    showOpts = RolesShow <$> roleArg
    roleArg = Text.pack <$> argument str (metavar "ROLE")
    permissionArg = Text.pack <$> argument str (metavar "PERMISSION")
    userOpt =
      Text.pack
        <$> strOption (long "user" <> metavar "USER_ID" <> help "Typed id (user_…) or bare UUID")
    roleOpt =
      Text.pack <$> strOption (long "role" <> metavar "NAME" <> help "Role name")
    -- '--expires-in' and '--expires-at' are alternatives: supplying both leaves the second flag
    -- unconsumed and optparse reports an error, which is the mutually-exclusive behaviour we want.
    expiryOpt =
      (ExpiresIn <$> option durationReader (long "expires-in" <> metavar "DURATION" <> help "Grant expiry as <n>(s|m|h|d), e.g. 4h"))
        <|> (ExpiresAt <$> option iso8601Reader (long "expires-at" <> metavar "ISO8601" <> help "Grant expiry as an ISO8601 UTC instant"))

-- | @<n>(s|m|h|d)@ → 'NominalDiffTime'. A bare number, a bad unit, or trailing junk is rejected.
durationReader :: ReadM NominalDiffTime
durationReader =
  eitherReader \s ->
    maybe (Left ("expected <n>(s|m|h|d), e.g. 4h; got " <> show s)) Right (parseDuration s)

parseDuration :: String -> Maybe NominalDiffTime
parseDuration s = case span isDigit s of
  (digits@(_ : _), [unit]) -> do
    n <- readInteger digits
    mult <- case unit of
      's' -> Just 1
      'm' -> Just 60
      'h' -> Just 3600
      'd' -> Just 86400
      _ -> Nothing
    pure (fromInteger (n * mult))
  _ -> Nothing
  where
    readInteger ds = case reads ds of
      [(n, "")] -> Just (n :: Integer)
      _ -> Nothing

-- | An ISO8601 UTC timestamp → 'UTCTime' (e.g. @2026-07-11T21:00:00Z@).
iso8601Reader :: ReadM UTCTime
iso8601Reader =
  eitherReader \s ->
    maybe (Left ("expected an ISO8601 UTC instant, e.g. 2026-07-11T21:00:00Z; got " <> show s)) Right (iso8601ParseM s)

-- Execution ------------------------------------------------------------------

-- | Resolve a parsed 'GrantExpiry' to an absolute instant. @--expires-in@ is relative to the CLI's
-- clock now; @--expires-at@ is already absolute. 'Nothing' (no flag) means the grant never expires.
resolveExpiry :: Maybe GrantExpiry -> IO (Maybe UTCTime)
resolveExpiry Nothing = pure Nothing
resolveExpiry (Just (ExpiresAt t)) = pure (Just t)
resolveExpiry (Just (ExpiresIn d)) = Just . addUTCTime d <$> getCurrentTime

-- | The outcome of a permission-wiring command, so the boundary (IO) prints or dies without the
-- eff needing to.
data AllowOutcome = AllowRoleUndefined | NewlyAllowed | AlreadyAllowed

data DisallowOutcome = DisallowRoleUndefined | Detached | NotAttached

data ShowOutcome = ShowRoleUndefined | ShowRole RoleDefinition (Set.Set Permission)

runRoles :: AdminEnv -> RolesCommand -> IO ()
runRoles env = \case
  RolesDefine rawRole mDesc -> do
    role <- parseRole rawRole
    fresh <- runOrDie env.pool do
      ts <- now
      defineRole role mDesc ts
    putStrLn
      if fresh
        then "defined role " <> roleString role
        else "role " <> roleString role <> " was already defined"
  RolesListDefined -> do
    defs <- runOrDie env.pool listDefinedRoles
    forM_ defs \d ->
      putStrLn (roleString d.role <> maybe "" (\t -> " — " <> Text.unpack t) d.description)
  RolesAllow rawRole rawPerm -> do
    role <- parseRole rawRole
    perm <- parsePermission rawPerm
    outcome <- runOrDie env.pool (allowEff role perm)
    case outcome of
      AllowRoleUndefined -> dieAuthError (RoleNotDefined role)
      NewlyAllowed -> putStrLn ("allowed " <> permString perm <> " for role " <> roleString role)
      AlreadyAllowed -> putStrLn ("role " <> roleString role <> " already allowed " <> permString perm)
  RolesDisallow rawRole rawPerm -> do
    role <- parseRole rawRole
    perm <- parsePermission rawPerm
    outcome <- runOrDie env.pool (disallowEff role perm)
    case outcome of
      DisallowRoleUndefined -> dieAuthError (RoleNotDefined role)
      Detached -> putStrLn ("disallowed " <> permString perm <> " for role " <> roleString role)
      NotAttached -> putStrLn ("role " <> roleString role <> " did not allow " <> permString perm)
  RolesShow rawRole -> do
    role <- parseRole rawRole
    outcome <- runOrDie env.pool (showEff role)
    case outcome of
      ShowRoleUndefined -> dieAuthError (RoleNotDefined role)
      ShowRole d perms -> do
        putStrLn (roleString d.role <> maybe "" (\t -> " — " <> Text.unpack t) d.description)
        if Set.null perms
          then putStrLn "  (no permissions)"
          else forM_ (Set.toList perms) \p -> putStrLn ("  " <> permString p)
  RolesGrant rawUser rawRole mExpiry -> do
    uid <- parseUserRef rawUser
    role <- parseRole rawRole
    expiry <- resolveExpiry mExpiry
    outcome <- runOrDie env.pool (grantRoleTo Nothing expiry uid role)
    case outcome of
      Left e -> dieAuthError e
      Right True ->
        putStrLn
          ( "granted "
              <> roleString role
              <> " to "
              <> Text.unpack (idText uid)
              <> maybe "" (\t -> " (expires " <> iso8601Show t <> ")") expiry
          )
      Right False -> putStrLn ("user already had role " <> roleString role)
  RolesRevoke rawUser rawRole -> do
    uid <- parseUserRef rawUser
    role <- parseRole rawRole
    outcome <- runOrDie env.pool (revokeRoleFrom Nothing uid role)
    case outcome of
      Left e -> dieAuthError e
      Right True -> putStrLn ("revoked " <> roleString role <> " from " <> Text.unpack (idText uid))
      Right False -> putStrLn "no such grant"
  RolesList rawUser -> do
    uid <- parseUserRef rawUser
    outcome <- runOrDie env.pool (rolesOf uid)
    case outcome of
      Left e -> dieAuthError e
      Right roles -> forM_ (Set.toList roles) (putStrLn . roleString)

-- | Attach a permission to a role, refusing an undefined role first (the FK backstops it, but a
-- typed pre-check gives a clear message and matches the grant path's behaviour).
allowEff :: (RoleStore :> es, Clock :> es) => Role -> Permission -> Eff es AllowOutcome
allowEff role perm = do
  defined <- definedRoleNames
  if not (role `Set.member` defined)
    then pure AllowRoleUndefined
    else do
      ts <- now
      changed <- allowPermission role perm ts
      pure (if changed then NewlyAllowed else AlreadyAllowed)

disallowEff :: (RoleStore :> es) => Role -> Permission -> Eff es DisallowOutcome
disallowEff role perm = do
  defined <- definedRoleNames
  if not (role `Set.member` defined)
    then pure DisallowRoleUndefined
    else do
      changed <- disallowPermission role perm
      pure (if changed then Detached else NotAttached)

showEff :: (RoleStore :> es) => Role -> Eff es ShowOutcome
showEff role = do
  defs <- listDefinedRoles
  case [d | d <- defs, d.role == role] of
    [] -> pure ShowRoleUndefined
    (d : _) -> ShowRole d <$> listPermissionsForRole role

definedRoleNames :: (RoleStore :> es) => Eff es (Set.Set Role)
definedRoleNames = Set.fromList . map (.role) <$> listDefinedRoles

-- | Render the typed errors the role workflows can return, each with the fix.
dieAuthError :: AuthError -> IO a
dieAuthError = \case
  UserNotFound -> die "user not found"
  RoleNotDefined (Role r) ->
    die
      ( "role not defined: "
          <> Text.unpack r
          <> " (define it first: shomei-admin roles define "
          <> Text.unpack r
          <> ")"
      )
  other -> die ("unexpected error: " <> show other)

-- | A user reference is either the typed id rendered by the API (@user_01ABC…@) or the bare
-- UUID the audit columns speak. Operators paste whichever they have in front of them.
parseUserRef :: Text -> IO UserId
parseUserRef raw =
  case parseId raw of
    Right uid -> pure uid
    Left _ ->
      case UUID.fromText raw of
        Just uuid -> pure (userIdFromUUID uuid)
        Nothing -> die ("--user must be a typed id (user_…) or a UUID, got " <> Text.unpack raw)

-- | Role names are trimmed and must be non-blank. Validating at the boundary keeps the
-- workflow free of shape checks (it treats a 'Role' as opaque text).
parseRole :: Text -> IO Role
parseRole raw =
  let trimmed = Text.strip raw
   in if Text.length raw > 128 || Text.null trimmed
        then die "role name must be non-blank and at most 128 characters"
        else pure (Role trimmed)

-- | Permission strings are trimmed and must be non-blank and contain no internal whitespace. The
-- @resource:verb@ shape is a documented convention, not enforced grammar — only blank/whitespace
-- strings are rejected.
parsePermission :: Text -> IO Permission
parsePermission raw =
  let trimmed = Text.strip raw
   in if Text.null trimmed || Text.any isSpace trimmed
        then die ("invalid permission: " <> show raw <> " (must be non-blank with no whitespace)")
        else pure (Permission trimmed)

roleString :: Role -> String
roleString (Role r) = Text.unpack r

permString :: Permission -> String
permString (Permission p) = Text.unpack p

-- | The minimal chain the role commands need, mirroring @Shomei.Admin.Users.runSignup@.
-- @UserStore@ is present because 'grantRoleTo' resolves the subject before granting.
runRolesEff ::
  Pool ->
  Eff '[UserStore, RoleStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->
  IO (Either AuthError a)
runRolesEff pool =
  runEff
    . runErrorNoCallStack
    . runDatabasePool pool
    . runClockIO
    . runAuthEventPublisherPostgres
    . runRoleStorePostgres
    . runUserStorePostgres

runOrDie ::
  Pool ->
  Eff '[UserStore, RoleStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->
  IO a
runOrDie pool act = do
  res <- runRolesEff pool act
  either (\e -> die ("database error: " <> show e)) pure res

die :: String -> IO a
die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure