packages feed

plexus-synapse-3.15.0: src/Synapse/Identity/Command.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE LambdaCase #-}

-- | IDENTITY-43: the command lines.
--
-- @
-- synapse _key new [NAME] [--store auto|file|keychain]   -- NAME defaults to "default"; never overwrites
-- synapse _key show [NAME]                                -- key:\<hex\>
-- synapse _key list
-- synapse [-H HOST] -P PORT _login BACKEND                       -- a person: the browser (AUTH-8)
-- synapse [-H HOST] -P PORT _login BACKEND [--key NAME] --as AGENT [--scope role\@SCOPE …] [--ttl SECS]
-- synapse [-H HOST] -P PORT _logout BACKEND [--as AGENT]
-- @
--
-- @_key@ is local and runs here; @_login@ / @_logout@ are parsed here and
-- run by the executable, which knows how to reach a backend.
module Synapse.Identity.Command
  ( -- * _key
    KeyCommand(..)
  , keyCommandInfo
  , runKeyCommand

    -- * _login / _logout
  , LoginOpts(..)
  , loginInfo
  , LogoutOpts(..)
  , logoutInfo
  , splitScopes
  ) where

import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Options.Applicative
import System.Exit (ExitCode(..))
import System.IO (stderr)

import Synapse.Identity.Proof
import Synapse.Identity.Store

-- ============================================================================
-- _key
-- ============================================================================

data KeyCommand
  = KeyNew Text StorePref
  | KeyShow Text
  | KeyList
  deriving (Eq, Show)

keyCommandInfo :: ParserInfo KeyCommand
keyCommandInfo = info (parser <**> helper)
  ( fullDesc
 <> header "synapse _key - the Ed25519 keys an agent proves who it is with"
 <> progDesc "Kept in the OS keychain when one answers, else ~/.plexus/keys/NAME.key (0600). Read in the order: PLEXUS_KEY (base64 seed), keychain, $CREDENTIALS_DIRECTORY/NAME.key, ~/.plexus/keys/NAME.key."
  )
  where
    parser = hsubparser
      ( command "new" (info newP (progDesc "Make a key (NAME defaults to 'default'); prints key:<hex>. Never overwrites."))
     <> command "show" (info showP (progDesc "Print a key's principal, key:<hex>"))
     <> command "list" (info (pure KeyList) (progDesc "Every key synapse can find, and where"))
      )
    nameArg = T.pack <$> strArgument (metavar "NAME" <> value "default" <> showDefault)
    newP = KeyNew <$> nameArg <*> option storeR
      ( long "store" <> metavar "auto|file|keychain" <> value StoreAuto
     <> help "Where to keep it (default auto: the keychain when it answers, else the file)" )
    showP = KeyShow <$> nameArg
    storeR = eitherReader $ \case
      "auto" -> Right StoreAuto
      "file" -> Right StoreFile
      "keychain" -> Right StoreKeychain
      s -> Left ("--store is auto, file or keychain, not " <> s)

runKeyCommand :: StoreEnv -> KeyCommand -> IO ExitCode
runKeyCommand st = \case
  KeyNew name pref
    | not (validKeyName name) -> bad ("a key name is 1-64 of A-Z a-z 0-9 . _ - (not '" <> name <> "')")
    | otherwise -> keyExists st name >>= \case
        Just src -> bad ("key " <> name <> " already exists (" <> renderKeySource src <> "); synapse never overwrites a key")
        Nothing -> do
          seed <- generateSeed
          writeKey st pref name seed >>= \case
            Left e -> bad e
            Right src -> do
              TIO.putStrLn (principalOf seed)
              TIO.hPutStrLn stderr ("synapse: key " <> name <> " kept in " <> renderKeySource src)
              pure ExitSuccess
  KeyShow name -> lookupKey st name >>= \case
    Left e -> bad e
    Right Nothing -> bad ("no key " <> name <> " (synapse _key new " <> name <> ")")
    Right (Just (seed, _)) -> ExitSuccess <$ TIO.putStrLn (principalOf seed)
  KeyList -> do
    keys <- listKeys st
    rows <- mapM describe keys
    if null rows
      then TIO.hPutStrLn stderr "synapse: no keys (synapse _key new)"
      else mapM_ TIO.putStrLn (align' rows)
    pure ExitSuccess
  where
    bad msg = ExitFailure 1 <$ TIO.hPutStrLn stderr ("synapse: " <> msg)
    describe (name, src) = do
      -- the principal from the store that listed it (PLEXUS_KEY would
      -- answer for every name, so it is not asked here)
      p <- lookupKey st { stEnv = \k -> if k == "PLEXUS_KEY" then pure Nothing else stEnv st k } name
      let principal = case p of
            Right (Just (seed, _)) -> principalOf seed
            Right Nothing -> "?"
            Left e -> "(" <> e <> ")"
      pure (name, principal, renderKeySource src)
    align' rows =
      let w = maximum [T.length n | (n, _, _) <- rows]
      in [T.justifyLeft w ' ' n <> "  " <> p <> "  " <> s | (n, p, s) <- rows]

-- ============================================================================
-- _login / _logout
-- ============================================================================

data LoginOpts = LoginOpts
  { loBackend :: Text
  , loKey     :: Maybe Text   -- ^ AUTH-8: none (and no agent named) is a person signing in through the browser
  , loAs      :: Maybe Text
  , loScope   :: [Text]
  , loTtl     :: Maybe Integer
  } deriving (Eq, Show)

loginInfo :: ParserInfo LoginOpts
loginInfo = info (p <**> helper)
  ( fullDesc
 <> header "synapse _login - sign in; later calls to this backend carry the session by themselves"
 <> progDesc "A person (no --key, no --as): opens the browser to sign in and waits on a loopback port (127.0.0.1) for it to come back (auth.login's browser flow; 5 minutes). An agent (--key and/or --as): proves the key (a plexus-key proof at the upgrade) and asks auth.login for a session (session.login on a hub without auth). Either way the session is kept (keychain, else ~/.plexus/sessions/HOST_PORT/AGENT.json; a person's under the name 'me') and becomes this backend's default. Global options (-H, -P) go before _login."
  )
  where
    p = LoginOpts
      <$> (T.pack <$> strArgument (metavar "BACKEND" <> help "The hub's name, e.g. menger"))
      <*> optional (T.pack <$> strOption (long "key" <> short 'k' <> metavar "NAME" <> help "The key to prove (an agent; 'default' when only --as is given)"))
      <*> optional (T.pack <$> strOption (long "as" <> metavar "AGENT" <> help "The agent id to log in as (else SYNAPSE_AS)"))
      <*> (splitScopes <$> many (T.pack <$> strOption (long "scope" <> metavar "ROLE@SCOPE" <> help "The session's ceiling, e.g. editor@MENGER-12 (repeat, or commas); default: the key's own grants")))
      <*> optional (option auto (long "ttl" <> metavar "SECS" <> help "Session lifetime in seconds (the server's default 12 h, at most 24 h)"))

data LogoutOpts = LogoutOpts
  { lgBackend :: Text
  , lgAs      :: Maybe Text
  } deriving (Eq, Show)

logoutInfo :: ParserInfo LogoutOpts
logoutInfo = info (p <**> helper)
  ( fullDesc
 <> header "synapse _logout - end a session (session.logout) and forget it"
  )
  where
    p = LogoutOpts
      <$> (T.pack <$> strArgument (metavar "BACKEND" <> help "The hub's name, e.g. menger"))
      <*> optional (T.pack <$> strOption (long "as" <> metavar "AGENT" <> help "Which agent's session (else SYNAPSE_AS, else the backend's default)"))

-- | @--scope a,b --scope c@ → @[a, b, c]@.
splitScopes :: [Text] -> [Text]
splitScopes = filter (not . T.null) . map T.strip . concatMap (T.splitOn ",")