packages feed

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

-- | @shomei-admin service-accounts@ (MasterPlan 7 EP-4): create, rotate, revoke, and list the
-- database-backed machine credentials that authenticate at @POST \/oauth\/token@ with the OAuth2
-- @client_credentials@ grant.
--
-- @
-- shomei-admin service-accounts create        --display-name TEXT [--scope S]...
-- shomei-admin service-accounts rotate-secret \<client_id\>
-- shomei-admin service-accounts revoke        \<client_id\>
-- shomei-admin service-accounts list
-- @
--
-- This is the runtime lifecycle the config-defined service accounts never had: creating,
-- rotating, or revoking one of those meant editing configuration and redeploying.
--
-- __The secret is generated here and printed exactly once.__ Only its SHA-256 digest is
-- persisted, so a lost secret cannot be recovered — it can only be replaced with
-- @rotate-secret@. The model is single-secret: rotation invalidates the old secret immediately,
-- with no overlap window. An operator who needs a zero-downtime handover creates a /second/
-- account, migrates consumers to it, then revokes the first.
--
-- Every service account owns a dedicated row in @shomei_users@, created here with @login_id@ set
-- to the account's @client_id@ and no password credential. That row is not a convenience: an
-- access token's @sub@ is a 'Shomei.Id.UserId' and @shomei_sessions.user_id@ has a foreign key
-- into @shomei_users@, so a token cannot be minted without one.
--
-- @create@, @rotate-secret@ and @revoke@ publish audit events; @list@ reads only, and never
-- prints a hash.
module Shomei.Admin.ServiceAccounts
  ( ServiceAccountsCommand (..),
    serviceAccountsParser,
    runServiceAccounts,

    -- * Actions

    --

    -- | The subcommands as functions that /return/ what they did, with printing left to
    --     'runServiceAccounts'. Exported so the integration suite can assert on the generated
    --     secret without capturing the process's stdout — which is not safe to do in a test
    --     runner that executes cases in parallel.
    createAction,
    rotateSecretAction,
    revokeAction,
    listAction,
  )
where

import Control.Monad (forM_, when)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (UTCTime)
import Effectful (Eff, IOE, runEff)
import Effectful.Error.Static (Error, runErrorNoCallStack)
import Options.Applicative
import Shomei.Account.LoginId.Domain (mkLoginId)
import Shomei.Account.Password.Hash.Postgres (generateOpaqueToken)
import Shomei.Persistence.Pool.Postgres (Pool)

-- 'ServiceAccount' shares the field names @userId@ / @createdAt@ / @displayName@ with
-- 'Shomei.Account.User.Domain.User'. Both are imported with @(..)@ and read by /record pattern/ only,
-- never through @OverloadedRecordDot@: a record pattern names its constructor and so is
-- unambiguous, while @user.userId@ would not be.

import Shomei.Account.User.Domain (NewUser (..), User (..))
import Shomei.Account.User.Postgres (runUserStorePostgres)
import Shomei.Account.User.Store (UserStore, createUser)
import Shomei.Admin.Env (AdminEnv (..))
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Audit.Publisher.Postgres (runAuthEventPublisherPostgres)
import Shomei.Audit.Publisher.Store (AuthEventPublisher, publishAuthEvent)
import Shomei.Authorization.Claims.Domain (Scope (..))
import Shomei.Authorization.Scope.Domain (privilegeScopesIn)
import Shomei.Error (AuthError)
import Shomei.Id (ServiceAccountDbId, UserId, genServiceAccountDbId, idText)
import Shomei.Persistence.Database.Postgres (Database, runDatabasePool)
import Shomei.ServiceAccount.Domain (NewServiceAccount (..), ServiceAccount (..), ServiceAccountStatus (..))
import Shomei.ServiceAccount.Postgres (runServiceAccountStorePostgres)
import Shomei.ServiceAccount.Secret (sha256Hex)
import Shomei.ServiceAccount.Store
  ( ServiceAccountStore,
    createServiceAccount,
    findServiceAccountByClientId,
    listServiceAccounts,
    revokeServiceAccount,
    rotateServiceAccountSecret,
  )
import Shomei.Time.Postgres (runClockIO)
import Shomei.Time.Store (Clock, now)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)

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

data ServiceAccountsCommand
  = ServiceAccountsCreate Text [Text]
  | ServiceAccountsRotateSecret Text
  | ServiceAccountsRevoke Text
  | ServiceAccountsList

serviceAccountsParser :: Parser ServiceAccountsCommand
serviceAccountsParser =
  hsubparser
    ( command "create" (info createOpts (progDesc "Create a service account; prints its secret once"))
        <> command "rotate-secret" (info (ServiceAccountsRotateSecret <$> clientIdArg) (progDesc "Replace the secret; the old one stops working immediately"))
        <> command "revoke" (info (ServiceAccountsRevoke <$> clientIdArg) (progDesc "Refuse all future tokens for this account"))
        <> command "list" (info (pure ServiceAccountsList) (progDesc "Show every service account (never its secret hash)"))
    )
  where
    createOpts =
      ServiceAccountsCreate
        <$> (Text.pack <$> strOption (long "display-name" <> metavar "TEXT" <> help "Human label, e.g. \"rei connector\""))
        <*> many (Text.pack <$> strOption (long "scope" <> metavar "SCOPE" <> help "A scope this account may request; repeatable"))
    clientIdArg = Text.pack <$> argument str (metavar "CLIENT_ID")

-- Field accessors ------------------------------------------------------------

saId :: ServiceAccount -> ServiceAccountDbId
saId ServiceAccount {serviceAccountId} = serviceAccountId

saClientId :: ServiceAccount -> Text
saClientId ServiceAccount {clientId} = clientId

saUserId :: ServiceAccount -> UserId
saUserId ServiceAccount {userId} = userId

saDisplayName :: ServiceAccount -> Text
saDisplayName ServiceAccount {displayName} = displayName

saAllowedScopes :: ServiceAccount -> Set Scope
saAllowedScopes ServiceAccount {allowedScopes} = allowedScopes

saStatus :: ServiceAccount -> ServiceAccountStatus
saStatus ServiceAccount {status} = status

saCreatedAt :: ServiceAccount -> UTCTime
saCreatedAt ServiceAccount {createdAt} = createdAt

saRotatedAt :: ServiceAccount -> Maybe UTCTime
saRotatedAt ServiceAccount {rotatedAt} = rotatedAt

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

-- | Create an account and its backing user, returning the account and the generated secret.
-- The secret is returned, never re-read: only its digest is persisted.
createAction :: AdminEnv -> Text -> [Text] -> IO (ServiceAccount, Text)
createAction env displayName rawScopes = do
  when (Text.length displayName > 256) (die "display name exceeds 256 characters")
  scopes <- parseScopes rawScopes
  forM_ (Set.toList (privilegeScopesIn env.config scopes)) \(Scope scope) ->
    hPutStrLn
      stderr
      ( "shomei-admin: warning: "
          <> Text.unpack scope
          <> " is a privilege scope; this account holds it as its own authority (see docs/user/security.md, \"Scopes are principal privilege\")"
      )
  secret <- generateOpaqueToken
  account <- runOrDie env.pool do
    said <- genServiceAccountDbId
    let cid = idText said
    ts <- now
    -- The backing principal. No password credential is created, so this row can never be
    -- logged into; it exists to satisfy the session/claims foreign keys.
    loginId <- either (const (error ("impossible: client id is not a valid login id: " <> Text.unpack cid))) pure (mkLoginId cid)
    User {userId = backingUserId} <- createUser NewUser {loginId, email = Nothing, displayName = Just displayName}
    account <-
      createServiceAccount
        NewServiceAccount
          { serviceAccountId = said,
            clientId = cid,
            userId = backingUserId,
            secretHash = sha256Hex secret,
            displayName,
            allowedScopes = scopes,
            createdAt = ts
          }
    publishAuthEvent
      ( Event.ServiceAccountCreated
          Event.ServiceAccountCreatedData
            { serviceAccountId = idText said,
              clientId = cid,
              userId = backingUserId,
              displayName,
              allowedScopes = scopes,
              occurredAt = ts
            }
      )
    pure account
  pure (account, secret)

-- | Replace the account's secret, returning it. The previous secret stops working immediately.
rotateSecretAction :: AdminEnv -> Text -> IO (ServiceAccount, Text)
rotateSecretAction env cid = do
  secret <- generateOpaqueToken
  account <- requireAccount env cid
  runOrDie env.pool do
    ts <- now
    rotateServiceAccountSecret (saId account) (sha256Hex secret) ts
    publishAuthEvent
      ( Event.ServiceAccountSecretRotated
          Event.ServiceAccountSecretRotatedData
            { serviceAccountId = idText (saId account),
              clientId = saClientId account,
              userId = saUserId account,
              occurredAt = ts
            }
      )
  pure (account, secret)

revokeAction :: AdminEnv -> Text -> IO ServiceAccount
revokeAction env cid = do
  account <- requireAccount env cid
  runOrDie env.pool do
    ts <- now
    revokeServiceAccount (saId account) ts
    publishAuthEvent
      ( Event.ServiceAccountRevoked
          Event.ServiceAccountRevokedData
            { serviceAccountId = idText (saId account),
              clientId = saClientId account,
              userId = saUserId account,
              occurredAt = ts
            }
      )
  pure account

listAction :: AdminEnv -> IO [ServiceAccount]
listAction env = runOrDie env.pool listServiceAccounts

runServiceAccounts :: AdminEnv -> ServiceAccountsCommand -> IO ()
runServiceAccounts env = \case
  ServiceAccountsCreate displayName rawScopes -> do
    (account, secret) <- createAction env displayName rawScopes
    putStrLn ("client_id:     " <> Text.unpack (saClientId account))
    putStrLn ("client_secret: " <> Text.unpack secret <> "  (shown once - store it now, it cannot be retrieved)")
    putStrLn ("scopes:        " <> renderScopes (saAllowedScopes account))
  ServiceAccountsRotateSecret cid -> do
    (account, secret) <- rotateSecretAction env cid
    putStrLn ("client_id:     " <> Text.unpack (saClientId account))
    putStrLn ("client_secret: " <> Text.unpack secret <> "  (shown once; the previous secret no longer works)")
  ServiceAccountsRevoke cid -> do
    account <- revokeAction env cid
    putStrLn ("revoked " <> Text.unpack (saClientId account))
    -- Access tokens are stateless JWTs: one minted a moment ago stays valid until it expires.
    putStrLn "note: tokens already issued to this account remain valid until they expire (default 5 minutes)"
  ServiceAccountsList -> do
    accounts <- listAction env
    forM_ accounts \a ->
      putStrLn
        ( Text.unpack (saClientId a)
            <> "  "
            <> renderStatus (saStatus a)
            <> "  "
            <> Text.unpack (saDisplayName a)
            <> "  scopes=["
            <> renderScopes (saAllowedScopes a)
            <> "]  created="
            <> show (saCreatedAt a)
            <> maybe "" (\t -> "  rotated=" <> show t) (saRotatedAt a)
        )

-- | Resolve a @client_id@ or exit 1. Every mutating subcommand names an account this way, so a
-- typo fails loudly instead of silently updating zero rows.
requireAccount :: AdminEnv -> Text -> IO ServiceAccount
requireAccount env cid = do
  found <- runOrDie env.pool (findServiceAccountByClientId cid)
  maybe (die ("no service account with client_id " <> Text.unpack cid)) pure found

renderStatus :: ServiceAccountStatus -> String
renderStatus = \case
  ServiceAccountActive -> "active "
  ServiceAccountRevoked -> "revoked"

renderScopes :: Set Scope -> String
renderScopes scopes = Text.unpack (Text.unwords [s | Scope s <- Set.toList scopes])

-- | Scopes are trimmed and must be non-blank; a scope containing whitespace could never be
-- expressed in the space-delimited OAuth2 @scope@ parameter.
parseScopes :: [Text] -> IO (Set Scope)
parseScopes raws = Set.fromList <$> traverse one raws
  where
    one raw =
      let trimmed = Text.strip raw
       in if Text.null trimmed || Text.any (== ' ') trimmed
            then die "invalid scope (must be non-blank, contain no spaces, and have at most 128 characters)"
            else pure (Scope trimmed)

-- | The minimal chain these commands need, mirroring @Shomei.Admin.Roles.runRolesEff@.
-- @UserStore@ is present because @create@ provisions the account's backing user row.
runServiceAccountsEff ::
  Pool ->
  Eff '[UserStore, ServiceAccountStore, AuthEventPublisher, Clock, Database, Error AuthError, IOE] a ->
  IO (Either AuthError a)
runServiceAccountsEff pool =
  runEff
    . runErrorNoCallStack
    . runDatabasePool pool
    . runClockIO
    . runAuthEventPublisherPostgres
    . runServiceAccountStorePostgres
    . runUserStorePostgres

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

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