packages feed

rdkafka-hs-1.1.0.0: test-integration/Kafka/Compatibility/SecurityFixture.hs

module Kafka.Compatibility.SecurityFixture
  ( SecurityFixture,
    createSecurityFixture,
    destroySecurityFixture,
    securityKafka,
    securitySettings,
    securityTopic,
    withSecurityCredentials,
    assertGroupAuthorizationDenied,
  )
where

import Control.Exception (onException)
import Control.Monad (forM_, unless, void, when)
import Data.List (isInfixOf, isPrefixOf)
import Data.Map.Strict qualified as M
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Kafka.Compatibility.KafkaFixture
import Kafka.TestEnv
import Kafka.Types
import System.Directory
import System.Posix.Files (setFileMode)
import Test.Tasty.HUnit (assertFailure)

-- No Show instance: passwords must never appear in assertion diagnostics.
data SecurityFixture = SecurityFixture
  { securityKafka :: KafkaFixture,
    identityPasswords :: M.Map String String
  }

labels :: [String]
labels = ["admin", "writer", "reader", "denied", "writer2", "reader2"]

username :: String -> String
username label = "fixture_" <> label

secret :: FilePath -> FilePath -> FilePath
secret root name = root <> "/secret/" <> name

readPassword :: FilePath -> String -> IO String
readPassword root label = T.unpack . T.strip <$> T.readFile (secret root (label <> ".password"))

prepare :: FilePath -> IO ()
prepare root = do
  createDirectory (root <> "/secret")
  setFileMode (root <> "/secret") 0o700
  forM_ ("tls" : labels) $ \label -> do
    void (ownedCommand root "openssl" ["rand", "-hex", "-out", secret root (label <> ".password"), "24"])
    setFileMode (secret root (label <> ".password")) 0o600
  let ssl = void . ownedCommand root "openssl"
  ssl ["req", "-x509", "-newkey", "rsa:2048", "-noenc", "-keyout", secret root "ca.key", "-out", secret root "ca.pem", "-days", "1", "-subj", "/CN=rdkafka-hs-fixture-ca"]
  ssl ["req", "-new", "-newkey", "rsa:2048", "-noenc", "-keyout", secret root "server.key", "-out", secret root "server.csr", "-subj", "/CN=localhost"]
  writeFile (secret root "server.ext") "subjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth\n"
  ssl ["x509", "-req", "-in", secret root "server.csr", "-CA", secret root "ca.pem", "-CAkey", secret root "ca.key", "-CAcreateserial", "-out", secret root "server.pem", "-days", "1", "-extfile", secret root "server.ext"]
  ssl ["pkcs12", "-export", "-in", secret root "server.pem", "-inkey", secret root "server.key", "-certfile", secret root "ca.pem", "-out", secret root "server.p12", "-passout", "file:" <> secret root "tls.password"]
  listDirectory (root <> "/secret") >>= mapM_ (\name -> setFileMode (secret root name) 0o600)
  writeFile (root <> "/log4j2.xml") "<Configuration status=\"WARN\"><Appenders><Console name=\"Console\" target=\"SYSTEM_OUT\"><PatternLayout pattern=\"%d %p %c %m%n\"/></Console></Appenders><Loggers><Logger name=\"kafka.authorizer.logger\" level=\"DEBUG\" additivity=\"false\"><AppenderRef ref=\"Console\"/></Logger><Root level=\"WARN\"><AppenderRef ref=\"Console\"/></Root></Loggers></Configuration>"

configuration :: FilePath -> Int -> Int -> Int -> IO [String]
configuration root port controller internal = do
  passwords <- mapM (readPassword root) labels
  adminPassword <- readPassword root "admin"
  tlsPassword <- readPassword root "tls"
  let login = "org.apache.kafka.common.security.plain.PlainLoginModule required username=\"" <> username "admin" <> "\" password=\"" <> adminPassword <> "\" " <> unwords ["user_" <> username label <> "=\"" <> password <> "\"" | (label, password) <- zip labels passwords] <> ";"
  writeFile (secret root "admin.properties") $
    unlines
      [ "security.protocol=SASL_SSL",
        "sasl.mechanism=PLAIN",
        "sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required username=\"" <> username "admin" <> "\" password=\"" <> adminPassword <> "\";",
        "ssl.truststore.type=PEM",
        "ssl.truststore.location=" <> secret root "ca.pem",
        "ssl.endpoint.identification.algorithm=https"
      ]
  setFileMode (secret root "admin.properties") 0o600
  pure
    [ "listeners=SASL_SSL://127.0.0.1:" <> show port <> ",INTERNAL://127.0.0.1:" <> show internal <> ",CONTROLLER://127.0.0.1:" <> show controller,
      "advertised.listeners=SASL_SSL://localhost:" <> show port <> ",INTERNAL://127.0.0.1:" <> show internal <> ",CONTROLLER://127.0.0.1:" <> show controller,
      "listener.security.protocol.map=CONTROLLER:PLAINTEXT,INTERNAL:PLAINTEXT,SASL_SSL:SASL_SSL",
      "inter.broker.listener.name=INTERNAL",
      "sasl.enabled.mechanisms=PLAIN",
      "listener.name.sasl_ssl.plain.sasl.jaas.config=" <> login,
      "ssl.keystore.type=PKCS12",
      "ssl.keystore.location=" <> secret root "server.p12",
      "ssl.keystore.password=" <> tlsPassword,
      "ssl.key.password=" <> tlsPassword,
      "ssl.client.auth=none",
      "authorizer.class.name=org.apache.kafka.metadata.authorizer.StandardAuthorizer",
      "super.users=User:ANONYMOUS;User:" <> username "admin",
      "allow.everyone.if.no.acl.found=false"
    ]

-- Stop all Java processes first, remove credentials/configuration, then inspect
-- retained logs. Even a failed setup enters this cleanup through the runner.
cleanup :: FilePath -> IO ()
cleanup root = do
  present <- doesDirectoryExist (root <> "/secret")
  passwords <- if present then mapM (\label -> doesFileExist (secret root (label <> ".password")) >>= \exists -> if exists then readPassword root label else pure "") ("tls" : labels) else pure []
  names <- listDirectory root
  forM_ names $ \name -> when ("node-" `isPrefixOf` name && ".properties" `isInfixOf` name || "command-" `isPrefixOf` name) (removeFile (root <> "/" <> name))
  when present (removeDirectoryRecursive (root <> "/secret"))
  -- No binary broker data is inspected; only owned, textual log files.
  let inspect path = do
        text <- T.unpack <$> T.readFile path
        let leaked = any (\password -> not (null password) && password `isInfixOf` text) passwords
        when leaked (writeFile path "Credential-bearing diagnostic removed by fixture cleanup.\n")
        pure leaked
      logs directory =
        listDirectory directory
          >>= fmap concat
            . mapM
              ( \name -> do
                  let path = directory <> "/" <> name
                  dir <- doesDirectoryExist path
                  if dir then if "-data" `isInfixOf` name then pure [] else logs path else if ".log" `isInfixOf` name then pure [path] else pure []
              )
  leaked <- logs root >>= mapM inspect
  writeFile (root <> "/security-cleanup.txt") "Owned credential files and JAAS configurations removed; owned log files checked for ephemeral passwords.\n"
  unless (not (or leaked)) (assertFailure "ephemeral credential appeared in an owned log; diagnostic removed")

createSecurityFixture :: IO SecurityFixture
createSecurityFixture = do
  kafka <- createSecureKafkaFixture (SecureExtension prepare configuration (\root -> [("KAFKA_LOG4J_OPTS", "-Dlog4j2.configurationFile=" <> root <> "/log4j2.xml")]) cleanup)
  flip onException (destroyKafkaFixture kafka) $ do
    passwords <- M.fromList <$> mapM (\label -> (label,) <$> readPassword (fixtureRoot kafka) label) labels
    let fixture = SecurityFixture kafka passwords
        environment = fixtureEnv kafka
        common = ["--bootstrap-server", T.unpack (T.intercalate "," (map unBrokerAddress (teBrokers environment))), "--command-config", secret (fixtureRoot kafka) "admin.properties"]
        cli command args = void (fixtureCommand kafka command (common <> args))
    forM_ ["primary", "replacement"] $ \suffix -> do
      let TopicName topic = securityTopic fixture suffix
      cli "kafka-topics" ["--create", "--topic", T.unpack topic, "--partitions", "1", "--replication-factor", "1"]
      forM_ [(if suffix == "primary" then "writer" else "writer2", ["Write", "Describe"]), (if suffix == "primary" then "reader" else "reader2", ["Read", "Describe"]), ("denied", ["Describe"])] $ \(label, operations) ->
        cli "kafka-acls" (["--add", "--allow-principal", "User:" <> username label, "--topic", T.unpack topic] <> concatMap (\operation -> ["--operation", operation]) operations)
    forM_ ["reader", "reader2", "denied"] $ \label ->
      cli "kafka-acls" ["--add", "--allow-principal", "User:" <> username label, "--operation", "Read", "--group", T.unpack (prefixedName environment "security-"), "--resource-pattern-type", "prefixed"]
    pure fixture

destroySecurityFixture :: SecurityFixture -> IO ()
destroySecurityFixture = destroyKafkaFixture . securityKafka

securitySettings :: SecurityFixture -> String -> M.Map T.Text T.Text
securitySettings fixture label =
  M.fromList
    [ ("security.protocol", "SASL_SSL"),
      ("sasl.mechanisms", "PLAIN"),
      ("sasl.username", T.pack (username label)),
      ("sasl.password", T.pack (identityPasswords fixture M.! label)),
      ("ssl.ca.location", T.pack (secret (fixtureRoot (securityKafka fixture)) "ca.pem")),
      ("ssl.endpoint.identification.algorithm", "https"),
      ("log_level", "0")
    ]

securityTopic :: SecurityFixture -> T.Text -> TopicName
securityTopic fixture suffix = TopicName (prefixedName (fixtureEnv (securityKafka fixture)) ("security-" <> suffix))

withSecurityCredentials :: SecurityFixture -> String -> (T.Text -> T.Text -> IO a) -> IO a
withSecurityCredentials fixture label action = action (T.pack (username label)) (T.pack (identityPasswords fixture M.! label))

assertGroupAuthorizationDenied :: SecurityFixture -> String -> T.Text -> IO ()
assertGroupAuthorizationDenied fixture label group = do
  output <- T.readFile (fixtureRoot (securityKafka fixture) <> "/node-1.stdout.log")
  let denied line = ("Principal = User:" <> T.pack (username label) <> " is Denied operation = DESCRIBE") `T.isInfixOf` line && ("on resource = Group:LITERAL:" <> group <> " for request = FindCoordinator") `T.isInfixOf` line
  unless (any denied (T.lines output)) (assertFailure "broker did not audit the exact denied group-coordinator request")