Complete runtime resilience and audit controls

This commit is contained in:
ms2k 2026-07-19 03:41:01 +09:00
commit 728abdbbf1
26 changed files with 1466 additions and 251 deletions

View file

@ -27,12 +27,13 @@ COPY app ./app
COPY src ./src COPY src ./src
COPY test ./test COPY test ./test
RUN cabal build exe:nuthatch exe:nuthatch-wp04-integration exe:nuthatch-wp09-phase exe:nuthatch-wp10-conformance \ RUN cabal build exe:nuthatch exe:nuthatch-wp04-integration exe:nuthatch-wp09-phase exe:nuthatch-wp10-conformance exe:nuthatch-wp12-runtime \
&& mkdir -p /out \ && mkdir -p /out \
&& cp "$(cabal list-bin exe:nuthatch)" /out/nuthatch \ && cp "$(cabal list-bin exe:nuthatch)" /out/nuthatch \
&& cp "$(cabal list-bin exe:nuthatch-wp04-integration)" /out/nuthatch-wp04-integration \ && cp "$(cabal list-bin exe:nuthatch-wp04-integration)" /out/nuthatch-wp04-integration \
&& cp "$(cabal list-bin exe:nuthatch-wp09-phase)" /out/nuthatch-wp09-phase \ && cp "$(cabal list-bin exe:nuthatch-wp09-phase)" /out/nuthatch-wp09-phase \
&& cp "$(cabal list-bin exe:nuthatch-wp10-conformance)" /out/nuthatch-wp10-conformance && cp "$(cabal list-bin exe:nuthatch-wp10-conformance)" /out/nuthatch-wp10-conformance \
&& cp "$(cabal list-bin exe:nuthatch-wp12-runtime)" /out/nuthatch-wp12-runtime
FROM debian:bookworm-slim AS runtime-base FROM debian:bookworm-slim AS runtime-base
@ -57,6 +58,7 @@ COPY --from=build /out/nuthatch /usr/local/bin/nuthatch
COPY --from=build /out/nuthatch-wp04-integration /usr/local/bin/nuthatch-wp04-integration COPY --from=build /out/nuthatch-wp04-integration /usr/local/bin/nuthatch-wp04-integration
COPY --from=build /out/nuthatch-wp09-phase /usr/local/bin/nuthatch-wp09-phase COPY --from=build /out/nuthatch-wp09-phase /usr/local/bin/nuthatch-wp09-phase
COPY --from=build /out/nuthatch-wp10-conformance /usr/local/bin/nuthatch-wp10-conformance COPY --from=build /out/nuthatch-wp10-conformance /usr/local/bin/nuthatch-wp10-conformance
COPY --from=build /out/nuthatch-wp12-runtime /usr/local/bin/nuthatch-wp12-runtime
CMD ["nuthatch"] CMD ["nuthatch"]
FROM runtime-base AS runtime FROM runtime-base AS runtime

View file

@ -1,7 +1,7 @@
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app check-streaming check-idempotency check-recovery check-protocol check-product app-up app-down test-frontend check-frontend build-frontend .PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app check-streaming check-idempotency check-recovery check-protocol check-product check-runtime app-up app-down test-frontend check-frontend build-frontend
test: test:
cabal test --offline nuthatch-unit ./tests/run-host-tests.sh
check-dependencies: check-dependencies:
./tests/kind/run.sh ./tests/kind/run.sh
@ -36,6 +36,10 @@ check-protocol:
check-product: test-frontend check-frontend build-frontend check-product: test-frontend check-frontend build-frontend
NUTHATCH_PRODUCT_GATE=1 ./tests/kind/wp05-run.sh NUTHATCH_PRODUCT_GATE=1 ./tests/kind/wp05-run.sh
check-runtime: test check-recovery
./tests/runtime-static.sh
./tests/kind/wp12-run.sh
app-up: app-up:
NUTHATCH_APP_INTERACTIVE=1 ./tests/kind/wp05-run.sh NUTHATCH_APP_INTERACTIVE=1 ./tests/kind/wp05-run.sh

View file

@ -1,7 +1,5 @@
module Main (main) where module Main (main) where
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, try)
import Data.String (fromString) import Data.String (fromString)
import Database.PostgreSQL.Simple (Only (..)) import Database.PostgreSQL.Simple (Only (..))
import Network.Wai.Handler.Warp import Network.Wai.Handler.Warp
@ -15,6 +13,7 @@ import Nuthatch
import Nuthatch.Db (migrate, query_, withDb) import Nuthatch.Db (migrate, query_, withDb)
import Nuthatch.Redis (withRedis) import Nuthatch.Redis (withRedis)
import Nuthatch.Recovery (withLinkedWorker) import Nuthatch.Recovery (withLinkedWorker)
import Nuthatch.Runtime (retryStartup)
import Nuthatch.S3 (newS3) import Nuthatch.S3 (newS3)
import System.Directory (doesDirectoryExist) import System.Directory (doesDirectoryExist)
import System.FilePath ((</>)) import System.FilePath ((</>))
@ -24,10 +23,20 @@ main :: IO ()
main = do main = do
hSetBuffering stdout LineBuffering hSetBuffering stdout LineBuffering
cfg <- loadConfig cfg <- loadConfig
waitForPostgres cfg retryStartup
(cfgStartupAttempts cfg)
(cfgStartupDelayMs cfg)
"postgresql"
(withDb cfg $ \db -> do
_ <- query_ db "SELECT 1" :: IO [Only Int]
pure ())
withDb cfg $ \db -> do withDb cfg $ \db -> do
schemaPath <- findSchema schemaPath <- findSchema
migrate db schemaPath cfg retryStartup
(cfgStartupAttempts cfg)
(cfgStartupDelayMs cfg)
"migration"
(migrate db schemaPath cfg)
withRedis cfg $ \redis -> do withRedis cfg $ \redis -> do
s3 <- newS3 cfg s3 <- newS3 cfg
let env = newEnv cfg db redis s3 let env = newEnv cfg db redis s3
@ -45,21 +54,6 @@ main = do
settings settings
(application env) (application env)
waitForPostgres :: Config -> IO ()
waitForPostgres cfg = go (60 :: Int)
where
go 0 = fail "postgres did not become ready"
go n = do
result <- try $ withDb cfg $ \db -> do
_ <- query_ db "SELECT 1" :: IO [Only Int]
pure ()
case result of
Right () -> pure ()
Left (e :: SomeException) -> do
putStrLn $ "waiting for postgres (" <> show n <> "): " <> show e
threadDelay 1000000
go (n - 1)
findSchema :: IO FilePath findSchema :: IO FilePath
findSchema = do findSchema = do
let candidates = let candidates =

View file

@ -19,8 +19,12 @@ data:
NUTHATCH_SEARCH_MAX_KEYS: "500" NUTHATCH_SEARCH_MAX_KEYS: "500"
NUTHATCH_SEARCH_TIMEOUT_MS: "2000" NUTHATCH_SEARCH_TIMEOUT_MS: "2000"
NUTHATCH_DELETE_PAGE_SIZE: "100" NUTHATCH_DELETE_PAGE_SIZE: "100"
NUTHATCH_LIST_CACHE_TTL_SEC: "30"
NUTHATCH_IDEMPOTENCY_WINDOW_SEC: "86400" NUTHATCH_IDEMPOTENCY_WINDOW_SEC: "86400"
NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC: "300" NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC: "300"
NUTHATCH_STARTUP_ATTEMPTS: "60"
NUTHATCH_STARTUP_DELAY_MS: "1000"
NUTHATCH_AUDIT_OBJECT_KEYS: "false"
--- ---
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret

View file

@ -57,6 +57,7 @@ common shared
, crypton >= 0.34 && < 1.1 , crypton >= 0.34 && < 1.1
, directory >= 1.3 && < 1.4 , directory >= 1.3 && < 1.4
, entropy >= 0.4 && < 0.5 , entropy >= 0.4 && < 0.5
, envparse >= 0.6 && < 0.7
, filepath >= 1.4 && < 1.6 , filepath >= 1.4 && < 1.6
, hedis >= 0.15 && < 0.16 , hedis >= 0.15 && < 0.16
, http-client >= 0.7 && < 0.8 , http-client >= 0.7 && < 0.8
@ -69,6 +70,7 @@ common shared
, lens >= 5.2 && < 5.4 , lens >= 5.2 && < 5.4
, memory >= 0.18 && < 0.19 , memory >= 0.18 && < 0.19
, mime-types >= 0.1 && < 0.2 , mime-types >= 0.1 && < 0.2
, modern-uri >= 0.3.6 && < 0.4
, mtl >= 2.3 && < 2.4 , mtl >= 2.3 && < 2.4
, network-uri >= 2.6 && < 2.8 , network-uri >= 2.6 && < 2.8
, postgresql-simple >= 0.7 && < 0.8 , postgresql-simple >= 0.7 && < 0.8
@ -108,6 +110,7 @@ library
Nuthatch.Protocol Nuthatch.Protocol
Nuthatch.Redis Nuthatch.Redis
Nuthatch.Recovery Nuthatch.Recovery
Nuthatch.Runtime
Nuthatch.S3 Nuthatch.S3
Nuthatch.Session Nuthatch.Session
Nuthatch.Streaming Nuthatch.Streaming
@ -172,6 +175,18 @@ executable nuthatch-wp10-conformance
-rtsopts -rtsopts
-with-rtsopts=-N -with-rtsopts=-N
executable nuthatch-wp12-runtime
import: shared
main-is: RuntimeProbe.hs
hs-source-dirs: test
build-depends:
, nuthatch
, base
ghc-options:
-threaded
-rtsopts
-with-rtsopts=-N
test-suite nuthatch-unit test-suite nuthatch-unit
import: shared import: shared
type: exitcode-stdio-1.0 type: exitcode-stdio-1.0

View file

@ -105,7 +105,7 @@ data SessionContext = SessionContext
{ sessionUser :: SessionUser { sessionUser :: SessionUser
, sessionCsrf :: Maybe CsrfToken , sessionCsrf :: Maybe CsrfToken
} }
deriving stock (Eq, Show) deriving stock (Eq)
data SessionAuth data SessionAuth
@ -170,7 +170,7 @@ data DeleteRequest = DeleteRequest
, deleteOperationId :: OperationId , deleteOperationId :: OperationId
, deleteCsrf :: CsrfToken , deleteCsrf :: CsrfToken
} }
deriving stock (Eq, Show) deriving stock (Eq)
instance FromJSON DeleteRequest where instance FromJSON DeleteRequest where
parseJSON = withObject "DeleteRequest" $ \o -> parseJSON = withObject "DeleteRequest" $ \o ->
@ -185,7 +185,7 @@ data DeletePrefixRequest = DeletePrefixRequest
, deletePrefixPath :: RelativePath , deletePrefixPath :: RelativePath
, deletePrefixCsrf :: CsrfToken , deletePrefixCsrf :: CsrfToken
} }
deriving stock (Eq, Show) deriving stock (Eq)
instance FromJSON DeletePrefixRequest where instance FromJSON DeletePrefixRequest where
parseJSON = withObject "DeletePrefixRequest" $ \o -> parseJSON = withObject "DeletePrefixRequest" $ \o ->
@ -378,7 +378,7 @@ data SessionResponse = SessionResponse
, sessionResponseDisplay :: Maybe Text , sessionResponseDisplay :: Maybe Text
, sessionResponseCsrf :: CsrfToken , sessionResponseCsrf :: CsrfToken
} }
deriving stock (Eq, Show) deriving stock (Eq)
instance ToJSON SessionResponse where instance ToJSON SessionResponse where
toJSON response = object toJSON response = object
@ -416,7 +416,7 @@ data DeleteBatchRequest = DeleteBatchRequest
, deleteBatchCsrf :: CsrfToken , deleteBatchCsrf :: CsrfToken
, deleteBatchItems :: [DeleteBatchItem] , deleteBatchItems :: [DeleteBatchItem]
} }
deriving stock (Eq, Show) deriving stock (Eq)
instance FromJSON DeleteBatchRequest where instance FromJSON DeleteBatchRequest where
parseJSON = withObject "DeleteBatchRequest" $ \o -> parseJSON = withObject "DeleteBatchRequest" $ \o ->

View file

@ -16,11 +16,13 @@ module Nuthatch.App
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (mapConcurrently_) import Control.Concurrent.Async (mapConcurrently_)
import Control.Exception import Control.Exception
( SomeException ( IOException
, SomeException
, fromException
, throwIO , throwIO
, try , tryJust
) )
import Control.Monad (forM, forever, when) import Control.Monad (forM, forM_, forever, when)
import Control.Monad.IO.Class (liftIO) import Control.Monad.IO.Class (liftIO)
import Control.Retry import Control.Retry
( retrying ) ( retrying )
@ -35,7 +37,7 @@ import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TIO import qualified Data.Text.IO as TIO
import Data.Word (Word64) import Data.Word (Word64)
import Database.PostgreSQL.Simple (Only (..)) import Database.PostgreSQL.Simple (Only (..), SqlError)
import GHC.Clock (getMonotonicTimeNSec) import GHC.Clock (getMonotonicTimeNSec)
import Network.HTTP.Types import Network.HTTP.Types
( hContentType ( hContentType
@ -51,7 +53,7 @@ import Network.HTTP.Types
import qualified Network.HTTP.Types.URI as URI import qualified Network.HTTP.Types.URI as URI
import Network.Wai (Request (..), responseFile, responseLBS) import Network.Wai (Request (..), responseFile, responseLBS)
import Nuthatch.Api import Nuthatch.Api
import Nuthatch.Audit (audit) import Nuthatch.Audit (audit, objectKeyFields)
import Nuthatch.Config (Config (..)) import Nuthatch.Config (Config (..))
import Nuthatch.Db (Db, ensurePersonalPool, withTransaction) import Nuthatch.Db (Db, ensurePersonalPool, withTransaction)
import qualified Nuthatch.Db as Db import qualified Nuthatch.Db as Db
@ -60,6 +62,7 @@ import Nuthatch.Paths
import Nuthatch.Protocol import Nuthatch.Protocol
import Nuthatch.Redis import Nuthatch.Redis
( Redis ( Redis
, cacheGeneration
, cacheGet , cacheGet
, cacheInvalidatePrefixes , cacheInvalidatePrefixes
, cacheSet , cacheSet
@ -68,6 +71,7 @@ import Nuthatch.Redis
) )
import Nuthatch.Recovery import Nuthatch.Recovery
( recoveryRetryPolicy ( recoveryRetryPolicy
, trySynchronous
) )
import Nuthatch.S3 import Nuthatch.S3
( ListPage (..) ( ListPage (..)
@ -242,10 +246,20 @@ authenticationHandler cfg = mkAuthHandler $ \request -> do
let cookies = requestCookies request let cookies = requestCookies request
sessionName = TE.encodeUtf8 (cfgCookieName cfg) sessionName = TE.encodeUtf8 (cfgCookieName cfg)
csrfName = TE.encodeUtf8 (cfgCsrfCookieName cfg) csrfName = TE.encodeUtf8 (cfgCsrfCookieName cfg)
token <- maybe (throwError loginRequired) pure (lookup sessionName cookies) token <- case lookup sessionName cookies of
Nothing -> do
liftIO $ audit "authentication" "rejected" Nothing
[("reason", A.String "missing_session")]
throwError loginRequired
Just value -> pure value
sessionToken <- parseCookieValue validateSessionToken token sessionToken <- parseCookieValue validateSessionToken token
verified <- liftIO (verifySession cfg sessionToken) verified <- liftIO (verifySession cfg sessionToken)
user <- either (const (throwError loginRequired)) pure verified user <- case verified of
Left _ -> do
liftIO $ audit "authentication" "rejected" Nothing
[("reason", A.String "invalid_session")]
throwError loginRequired
Right value -> pure value
csrf <- traverse csrf <- traverse
(parseCookieValue validateCsrfToken) (parseCookieValue validateCsrfToken)
(lookup csrfName cookies) (lookup csrfName cookies)
@ -260,17 +274,19 @@ authenticationHandler cfg = mkAuthHandler $ \request -> do
} }
parseCookieValue validate bytes = parseCookieValue validate bytes =
case TE.decodeUtf8' bytes of case TE.decodeUtf8' bytes of
Left _ -> throwOperationError err400 MalformedAuthentication Left _ -> rejectMalformed
Right value -> Right value ->
either either (const rejectMalformed) pure (validate value)
(const (throwOperationError err400 MalformedAuthentication)) rejectMalformed = do
pure liftIO $ audit "authentication" "rejected" Nothing
(validate value) [("reason", A.String "malformed_credential")]
throwOperationError err400 MalformedAuthentication
handleReady :: Env -> Handler ReadyResponse handleReady :: Env -> Handler ReadyResponse
handleReady env = do handleReady env = do
pg <- liftIO $ pg <- liftIO $
try (Db.query_ (envDb env) "SELECT 1") :: Handler (Either SomeException [Only Int]) trySynchronous (Db.query_ (envDb env) "SELECT 1")
:: Handler (Either SomeException [Only Int])
case pg of case pg of
Right _ -> pure (ReadyResponse True) Right _ -> pure (ReadyResponse True)
Left _ -> throwOperationError err503 ReadyDependencyUnavailable Left _ -> throwOperationError err503 ReadyDependencyUnavailable
@ -281,10 +297,13 @@ handleLoginRequest
handleLoginRequest env = do handleLoginRequest env = do
let cfg = envConfig env let cfg = envConfig env
authorizationResult <- liftIO (beginAuthorization cfg) authorizationResult <- liftIO (beginAuthorization cfg)
authorization <- either authorization <- case authorizationResult of
(const (throwOperationError err502 OidcAuthorizationFailed)) Left _ -> do
pure liftIO $ audit "login_start" "rejected" Nothing
authorizationResult [("reason", A.String "provider_unavailable")]
throwOperationError err502 OidcAuthorizationFailed
Right value -> pure value
liftIO $ audit "login_start" "ok" Nothing []
pure $ pure $
addHeader addHeader
(cookieValue (cookieValue
@ -304,10 +323,12 @@ handleOidcCallback env code state cookieHeader = do
let cfg = envConfig env let cfg = envConfig env
cookies = parseCookies (TE.encodeUtf8 cookieHeader) cookies = parseCookies (TE.encodeUtf8 cookieHeader)
transactionName = TE.encodeUtf8 (cfgOidcTransactionCookieName cfg) transactionName = TE.encodeUtf8 (cfgOidcTransactionCookieName cfg)
transaction <- maybe transactionBytes <- case lookup transactionName cookies of
(throwOperationError err400 OidcCallbackRejected) Nothing -> rejectCallback "missing_transaction"
(parseCookieValueText OidcCallbackRejected) Just value -> pure value
(lookup transactionName cookies) transaction <- case TE.decodeUtf8' transactionBytes of
Right value | not (T.null value) && T.length value <= 8192 -> pure value
_ -> rejectCallback "malformed_transaction"
identityResult <- liftIO (redeemAuthorizationCode cfg transaction state code) identityResult <- liftIO (redeemAuthorizationCode cfg transaction state code)
identity <- case identityResult of identity <- case identityResult of
Left reason -> do Left reason -> do
@ -323,12 +344,21 @@ handleOidcCallback env code state cookieHeader = do
(identitySubject identity) (identitySubject identity)
(identityDisplay identity) (identityDisplay identity)
token <- either token <- either
(const (throwOperationError err500 (SessionCreationFailed "session creation failed"))) (\_ -> do
liftIO $ audit "login" "rejected" (Just (unUserId uid))
[("reason", A.String "session_creation_failed")]
throwOperationError err500
(SessionCreationFailed "session creation failed"))
pure pure
tokenResult tokenResult
csrf <- liftIO newCsrfToken csrf <- liftIO newCsrfToken
liftIO $ audit "login" "ok" (Just (unUserId uid)) [] liftIO $ audit "login" "ok" (Just (unUserId uid)) []
pure (loginResponse cfg token csrf) pure (loginResponse cfg token csrf)
where
rejectCallback reason = do
liftIO $ audit "login" "rejected" Nothing
[("reason", A.String reason)]
throwOperationError err400 OidcCallbackRejected
loginResponse :: Config -> SessionToken -> CsrfToken -> LoginSuccess loginResponse :: Config -> SessionToken -> CsrfToken -> LoginSuccess
loginResponse cfg token csrf = loginResponse cfg token csrf =
@ -346,8 +376,10 @@ handleLogoutRequest
-> CsrfToken -> CsrfToken
-> Handler LogoutSuccess -> Handler LogoutSuccess
handleLogoutRequest env session providedCsrf = do handleLogoutRequest env session providedCsrf = do
requireCsrf LogoutCsrfRejected session providedCsrf requireCsrf "logout" LogoutCsrfRejected session providedCsrf
let cfg = envConfig env let cfg = envConfig env
liftIO $ audit "logout" "ok"
(Just (unUserId (suUserId (sessionUser session)))) []
pure $ pure $
addHeader (expiredCookieValue (cfgCookieName cfg) "/") $ addHeader (expiredCookieValue (cfgCookieName cfg) "/") $
addHeader (expiredCookieValue (cfgCsrfCookieName cfg) "/") $ addHeader (expiredCookieValue (cfgCsrfCookieName cfg) "/") $
@ -414,9 +446,9 @@ handleDownloadRequest env session root path requestedRange = do
let inspect = inspectBandwidth env user let inspect = inspectBandwidth env user
body = getObjectSource (envS3 env) key (transferPieceSize env) inspect body = getObjectSource (envS3 env) key (transferPieceSize env) inspect
liftIO $ audit "download" "ok" (Just (unUserId (suUserId user))) liftIO $ audit "download" "ok" (Just (unUserId (suUserId user)))
[ ("root", A.toJSON (rootLabel root)) ([ ("root", A.toJSON (rootLabel root))
, ("bytes", A.toJSON (oiSize info)) , ("bytes", A.toJSON (oiSize info))
] ] <> objectKeyAudit (envConfig env) path)
let filename = basename path let filename = basename path
pure $ pure $
addHeader (contentDisposition filename) $ addHeader (contentDisposition filename) $
@ -436,24 +468,49 @@ handleUploadRequest
-> SourceIO BS.ByteString -> SourceIO BS.ByteString
-> Handler UploadResponse -> Handler UploadResponse
handleUploadRequest env session root path operation declaredSize contentIdentity providedCsrf bodySource = do handleUploadRequest env session root path operation declaredSize contentIdentity providedCsrf bodySource = do
requireCsrf UploadCsrfRejected session providedCsrf requireCsrf "put" UploadCsrfRejected session providedCsrf
let user = sessionUser session let user = sessionUser session
size = unDeclaredSize declaredSize size = unDeclaredSize declaredSize
configuredMaximum = cfgMaxObjectBytes (envConfig env) configuredMaximum = cfgMaxObjectBytes (envConfig env)
when (size > configuredMaximum) $ when (size > configuredMaximum) $ do
liftIO $ audit "put" "rejected" (Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("bytes", A.toJSON size)
, ("operation_id", A.toJSON (unOperationId operation))
, ("reason", A.String "object_too_large")
] <> objectKeyAudit (envConfig env) path)
throwOperationError err413 UploadTooLarge throwOperationError err413 UploadTooLarge
requireUploadBandwidth env user size requireUploadBandwidth env user root path operation size
outcome <- liftIO $ putAttempt <- liftIO $
runPut env user root path operation size contentIdentity bodySource tryRecoveryDependency
result <- either (runPut env user root path operation size contentIdentity bodySource)
throwPutFailure outcome <- case putAttempt of
pure Left _ -> do
outcome liftIO $ audit "put" "failed" (Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("bytes", A.toJSON size)
, ("operation_id", A.toJSON (unOperationId operation))
, ("reason", A.String "database_unavailable")
] <> objectKeyAudit (envConfig env) path)
throwOperationError err503
(UploadMutationRejected "database unavailable")
Right value -> pure value
result <- case outcome of
Left failure -> do
liftIO $ audit "put" "failed" (Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("bytes", A.toJSON size)
, ("operation_id", A.toJSON (unOperationId operation))
, ("reason", A.String (putFailureReason failure))
] <> objectKeyAudit (envConfig env) path)
throwPutFailure failure
Right value -> pure value
liftIO $ audit "put" (mutationAuditResult result) (Just (unUserId (suUserId user))) liftIO $ audit "put" (mutationAuditResult result) (Just (unUserId (suUserId user)))
[ ("root", A.toJSON (rootLabel root)) ([ ("root", A.toJSON (rootLabel root))
, ("bytes", A.toJSON size) , ("bytes", A.toJSON size)
, ("operation_id", A.toJSON (unOperationId operation)) , ("operation_id", A.toJSON (unOperationId operation))
] ] <> mutationReasonFields result
<> objectKeyAudit (envConfig env) path)
pure UploadResponse pure UploadResponse
{ uploadResponseOperationId = operation { uploadResponseOperationId = operation
, uploadResponseResult = result , uploadResponseResult = result
@ -474,8 +531,15 @@ requireDownloadBandwidth env user size = do
Right False -> throwOperationError err429 DownloadBandwidthExceeded Right False -> throwOperationError err429 DownloadBandwidthExceeded
Right True -> pure () Right True -> pure ()
requireUploadBandwidth :: Env -> SessionUser -> Integer -> Handler () requireUploadBandwidth
requireUploadBandwidth env user size = do :: Env
-> SessionUser
-> Root
-> RelativePath
-> OperationId
-> Integer
-> Handler ()
requireUploadBandwidth env user root path operation size = do
available <- liftIO $ available <- liftIO $
throttleCheck throttleCheck
(envRedis env) (envRedis env)
@ -483,9 +547,19 @@ requireUploadBandwidth env user size = do
(suUserId user) (suUserId user)
(min size (toInteger (transferPieceSize env))) (min size (toInteger (transferPieceSize env)))
case available of case available of
Left _ -> throwOperationError err503 UploadThrottleUnavailable Left _ -> reject "throttle_unavailable" err503 UploadThrottleUnavailable
Right False -> throwOperationError err429 UploadBandwidthExceeded Right False ->
reject "bandwidth_exceeded" err429 UploadBandwidthExceeded
Right True -> pure () Right True -> pure ()
where
reject reason status response = do
liftIO $ audit "put" "rejected" (Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("bytes", A.toJSON size)
, ("operation_id", A.toJSON (unOperationId operation))
, ("reason", A.String reason)
] <> objectKeyAudit (envConfig env) path)
throwOperationError status response
inspectBandwidth :: Env -> SessionUser -> BS.ByteString -> IO () inspectBandwidth :: Env -> SessionUser -> BS.ByteString -> IO ()
inspectBandwidth env user chunk = do inspectBandwidth env user chunk = do
@ -509,21 +583,40 @@ handleDeleteRequest
-> DeleteRequest -> DeleteRequest
-> Handler DeleteResponse -> Handler DeleteResponse
handleDeleteRequest env session request = do handleDeleteRequest env session request = do
requireCsrf DeleteCsrfRejected session (deleteCsrf request) requireCsrf "delete" DeleteCsrfRejected session (deleteCsrf request)
let user = sessionUser session let user = sessionUser session
operation = deleteOperationId request operation = deleteOperationId request
root = deleteRoot request root = deleteRoot request
outcome <- liftIO $ deleteAttempt <- liftIO $
runDelete env user root (deletePath request) operation tryRecoveryDependency
result <- either (runDelete env user root (deletePath request) operation)
(throwOperationError err409 . DeleteMutationRejected) outcome <- case deleteAttempt of
pure Left _ -> do
outcome liftIO $ audit "delete" "failed"
(Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("operation_id", A.toJSON (unOperationId operation))
, ("reason", A.String "database_unavailable")
] <> objectKeyAudit (envConfig env) (deletePath request))
throwOperationError err503
(DeleteMutationRejected "database unavailable")
Right value -> pure value
result <- case outcome of
Left reason -> do
liftIO $ audit "delete" "failed"
(Just (unUserId (suUserId user)))
([ ("root", A.toJSON (rootLabel root))
, ("operation_id", A.toJSON (unOperationId operation))
, ("reason", A.String "object_store_unavailable")
] <> objectKeyAudit (envConfig env) (deletePath request))
throwOperationError err409 (DeleteMutationRejected reason)
Right value -> pure value
liftIO $ audit "delete" (mutationAuditResult result) liftIO $ audit "delete" (mutationAuditResult result)
(Just (unUserId (suUserId user))) (Just (unUserId (suUserId user)))
[ ("root", A.toJSON (rootLabel root)) ([ ("root", A.toJSON (rootLabel root))
, ("operation_id", A.toJSON (unOperationId operation)) , ("operation_id", A.toJSON (unOperationId operation))
] ] <> mutationReasonFields result
<> objectKeyAudit (envConfig env) (deletePath request))
pure DeleteResponse pure DeleteResponse
{ deleteResponseOperationId = operation { deleteResponseOperationId = operation
, deleteResponseResult = result , deleteResponseResult = result
@ -536,8 +629,13 @@ handleDeletePrefixRequest
-> SessionContext -> SessionContext
-> DeletePrefixRequest -> DeletePrefixRequest
-> Handler DeletePrefixResponse -> Handler DeletePrefixResponse
handleDeletePrefixRequest _env session request = do handleDeletePrefixRequest env session request = do
requireCsrf DeletePrefixCsrfRejected session (deletePrefixCsrf request) requireCsrf "delete_prefix" DeletePrefixCsrfRejected session (deletePrefixCsrf request)
liftIO $ audit "delete_prefix" "rejected"
(Just (unUserId (suUserId (sessionUser session))))
([ ("root", A.toJSON (rootLabel (deletePrefixRoot request)))
, ("reason", A.String "paged_delete_required")
] <> objectKeyAudit (envConfig env) (deletePrefixPath request))
throwOperationError err409 throwOperationError err409
(DeletePrefixMutationRejected (DeletePrefixMutationRejected
"use delete-plan and delete-batch with client operation ids") "use delete-plan and delete-batch with client operation ids")
@ -585,18 +683,41 @@ handleDeleteBatchRequest
-> DeleteBatchRequest -> DeleteBatchRequest
-> Handler DeleteBatchResponse -> Handler DeleteBatchResponse
handleDeleteBatchRequest env session request = do handleDeleteBatchRequest env session request = do
requireCsrf DeletePrefixCsrfRejected session (deleteBatchCsrf request) requireCsrf "delete_batch" DeletePrefixCsrfRejected session (deleteBatchCsrf request)
let items = deleteBatchItems request let items = deleteBatchItems request
when (null items || length items > cfgDeletePageSize (envConfig env)) $ when (null items || length items > cfgDeletePageSize (envConfig env)) $ do
liftIO $ audit "delete_batch" "rejected"
(Just (unUserId (suUserId (sessionUser session))))
[ ("root", A.toJSON (rootLabel (deleteBatchRoot request)))
, ("reason", A.String "invalid_batch_size")
]
throwOperationError err409 throwOperationError err409
(DeletePrefixMutationRejected "delete batch size is invalid") (DeletePrefixMutationRejected "delete batch size is invalid")
results <- liftIO $ forM items $ \item -> do results <- forM items $ \item -> do
outcome <- runDelete deleteAttempt <- liftIO $ tryRecoveryDependency $
env runDelete
(sessionUser session) env
(deleteBatchRoot request) (sessionUser session)
(deleteBatchPath item) (deleteBatchRoot request)
(deleteBatchOperationId item) (deleteBatchPath item)
(deleteBatchOperationId item)
let outcome = case deleteAttempt of
Left _ -> Left "database unavailable"
Right value -> value
failureReason = case deleteAttempt of
Left _ -> "database_unavailable"
Right (Left _) -> "object_store_unavailable"
Right (Right _) -> ""
liftIO $ audit "delete" (either (const "failed") mutationAuditResult outcome)
(Just (unUserId (suUserId (sessionUser session))))
([ ("root", A.toJSON (rootLabel (deleteBatchRoot request)))
, ("operation_id", A.toJSON
(unOperationId (deleteBatchOperationId item)))
] <> either
(const [("reason", A.String failureReason)])
mutationReasonFields
outcome
<> objectKeyAudit (envConfig env) (deleteBatchPath item))
pure $ case outcome of pure $ case outcome of
Left message -> DeleteItemResponse Left message -> DeleteItemResponse
{ deleteItemPath = deleteBatchPath item { deleteItemPath = deleteBatchPath item
@ -612,11 +733,23 @@ handleDeleteBatchRequest env session request = do
} }
pure (DeleteBatchResponse results) pure (DeleteBatchResponse results)
requireCsrf :: A.ToJSON error => error -> SessionContext -> CsrfToken -> Handler () requireCsrf
requireCsrf csrfError session provided = :: A.ToJSON error
=> Text
-> error
-> SessionContext
-> CsrfToken
-> Handler ()
requireCsrf action csrfError session provided =
case sessionCsrf session of case sessionCsrf session of
Just expected | constantTimeEq expected provided -> pure () Just expected | constantTimeEq expected provided -> pure ()
_ -> throwOperationError err403 csrfError _ -> do
liftIO $ audit "authorization" "rejected"
(Just (unUserId (suUserId (sessionUser session))))
[ ("reason", A.String "csrf_rejected")
, ("operation", A.String action)
]
throwOperationError err403 csrfError
throwOperationError :: A.ToJSON error => ServerError -> error -> Handler a throwOperationError :: A.ToJSON error => ServerError -> error -> Handler a
throwOperationError serverError operationError = throwOperationError serverError operationError =
@ -671,6 +804,34 @@ mutationAuditResult MutationFailed = "failed"
mutationAuditResult MutationRejected = "rejected" mutationAuditResult MutationRejected = "rejected"
mutationAuditResult MutationConflict = "conflict" mutationAuditResult MutationConflict = "conflict"
mutationReasonFields :: MutationResult -> [(Text, A.Value)]
mutationReasonFields MutationSucceeded = []
mutationReasonFields MutationPending =
[("reason", A.String "settlement_pending")]
mutationReasonFields MutationFailed =
[("reason", A.String "store_effect_failed")]
mutationReasonFields MutationRejected =
[("reason", A.String "admission_rejected")]
mutationReasonFields MutationConflict =
[("reason", A.String "operation_conflict")]
putFailureReason :: PutFailure -> Text
putFailureReason (PutDispatchFailed (PutObjectStreamError streamError)) =
case streamError of
UploadStreamInvalid _ -> "invalid_body"
UploadStreamSizeMismatch -> "size_mismatch"
UploadStreamIdentityMismatch -> "content_identity_mismatch"
putFailureReason (PutDispatchFailed (PutObjectBandwidthError bandwidthError)) =
case bandwidthError of
BandwidthUnavailable -> "throttle_unavailable"
BandwidthExceeded -> "bandwidth_exceeded"
putFailureReason (PutDispatchFailed (PutObjectStoreError _)) =
"object_store_unavailable"
objectKeyAudit :: Config -> RelativePath -> [(Text, A.Value)]
objectKeyAudit cfg =
objectKeyFields (cfgAuditObjectKeys cfg) . unRelativePath
contentDisposition :: Text -> Text contentDisposition :: Text -> Text
contentDisposition filename = contentDisposition filename =
"attachment; filename=\"" <> fallback <> "\"; filename*=UTF-8''" <> encoded "attachment; filename=\"" <> fallback <> "\"; filename*=UTF-8''" <> encoded
@ -866,9 +1027,13 @@ listEntries
-> Maybe Text -> Maybe Text
-> IO (Either Text ([ListEntry], Maybe Text)) -> IO (Either Text ([ListEntry], Maybe Text))
listEntries env user root prefix continuation = do listEntries env user root prefix continuation = do
let cacheKey = generation <- cacheGeneration (envRedis env) (suUserId user) root
let cacheOwner = case root of
Personal -> unUserId (suUserId user)
Shared -> "shared"
cacheKey =
"nuthatch:list:" "nuthatch:list:"
<> unUserId (suUserId user) <> cacheOwner
<> ":" <> ":"
<> rootLabel root <> rootLabel root
<> ":" <> ":"
@ -876,6 +1041,7 @@ listEntries env user root prefix continuation = do
<> ":" <> ":"
<> T.pack (show (cfgBrowsePageSize (envConfig env))) <> T.pack (show (cfgBrowsePageSize (envConfig env)))
<> ":" <> ":"
<> maybe "unavailable" TE.decodeUtf8 generation
s3Prefix = s3Prefix =
case root of case root of
Personal -> "users/" <> unUserId (suUserId user) <> "/" <> prefixSlash Personal -> "users/" <> unUserId (suUserId user) <> "/" <> prefixSlash
@ -884,9 +1050,9 @@ listEntries env user root prefix continuation = do
| T.null prefix = "" | T.null prefix = ""
| T.isSuffixOf "/" prefix = prefix | T.isSuffixOf "/" prefix = prefix
| otherwise = prefix <> "/" | otherwise = prefix <> "/"
cached <- case continuation of cached <- case (continuation, generation) of
Nothing -> cacheGet (envRedis env) cacheKey (Nothing, Just _) -> cacheGet (envRedis env) cacheKey
Just _ -> pure Nothing _ -> pure Nothing
case cached >>= A.decodeStrict of case cached >>= A.decodeStrict of
Just result -> pure (Right result) Just result -> pure (Right result)
Nothing -> do Nothing -> do
@ -928,8 +1094,11 @@ listEntries env user root prefix continuation = do
] ]
entries = folders <> files entries = folders <> files
result = (entries, lpContinuationToken lp) result = (entries, lpContinuationToken lp)
when (continuation == Nothing) $ when (continuation == Nothing && generation /= Nothing) $
cacheSet (envRedis env) cacheKey (cfgListCacheTtlSec (envConfig env)) cacheSet
(envRedis env)
cacheKey
(cfgListCacheTtlSec (envConfig env))
(BL.toStrict (A.encode result)) (BL.toStrict (A.encode result))
pure (Right result) pure (Right result)
@ -1018,9 +1187,26 @@ collectKeysBoundedWith currentTime listPage maxKeys deadline =
runRecovery :: Env -> IO () runRecovery :: Env -> IO ()
runRecovery env = forever $ do runRecovery env = forever $ do
recoverOnce env recovered <- tryRecoveryDependency (recoverOnce env)
case recovered of
Left _ ->
audit "dependency_runtime" "unavailable" Nothing
[ ("dependency", A.String "postgresql")
, ("reason", A.String "recovery_cycle_failed")
]
Right () -> pure ()
threadDelay (5 * 1000000) threadDelay (5 * 1000000)
tryRecoveryDependency :: IO value -> IO (Either SomeException value)
tryRecoveryDependency =
tryJust $ \exception ->
case ( fromException exception :: Maybe SqlError
, fromException exception :: Maybe IOException
) of
(Just _, _) -> Just exception
(_, Just _) -> Just exception
_ -> Nothing
recoverOnce :: Env -> IO () recoverOnce :: Env -> IO ()
recoverOnce env = do recoverOnce env = do
let grace = cfgRecoveryGraceSec (envConfig env) let grace = cfgRecoveryGraceSec (envConfig env)
@ -1051,6 +1237,15 @@ finalizeRecovered env rec = do
observed <- observeObjectWithRetry env rec observed <- observeObjectWithRetry env rec
result <- withTransaction (envDb env) $ \conn -> result <- withTransaction (envDb env) $ \conn ->
finalizeFromObservation conn rec observed finalizeFromObservation conn rec observed
forM_ result $ \mutationResult ->
audit "recovery_mutation" (mutationAuditResult mutationResult)
(Just (unUserId (mrUserId rec)))
([ ("root", A.toJSON (rootLabel (recordRoot rec)))
, ("operation_id", A.toJSON (unOperationId (mrOperationId rec)))
] <> mutationReasonFields mutationResult
<> objectKeyAudit
(envConfig env)
(mrRelativePath rec))
when (result == Just MutationSucceeded) $ when (result == Just MutationSucceeded) $
cacheInvalidatePrefixes cacheInvalidatePrefixes
(envRedis env) (envRedis env)
@ -1098,13 +1293,3 @@ lastSeg t =
requestCookies :: Request -> [(BS.ByteString, BS.ByteString)] requestCookies :: Request -> [(BS.ByteString, BS.ByteString)]
requestCookies request = requestCookies request =
maybe [] parseCookies (lookup hCookie (requestHeaders request)) maybe [] parseCookies (lookup hCookie (requestHeaders request))
parseCookieValueText
:: A.ToJSON error
=> error
-> BS.ByteString
-> Handler Text
parseCookieValueText operationError bytes =
case TE.decodeUtf8' bytes of
Right value | not (T.null value) && T.length value <= 8192 -> pure value
_ -> throwOperationError err400 operationError

View file

@ -1,22 +1,52 @@
module Nuthatch.Audit module Nuthatch.Audit
( audit ( audit
, auditValue
, objectKeyFields
) where ) where
import Data.Aeson (Value, encode, object, (.=)) import Data.Aeson (Value (String), encode, object, (.=))
import qualified Data.Aeson.Key as Key import qualified Data.Aeson.Key as Key
import qualified Data.ByteString.Lazy.Char8 as L8 import qualified Data.ByteString.Lazy.Char8 as L8
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text) import Data.Text (Text)
import Data.Time.Clock (getCurrentTime) import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Time.Format.ISO8601 (iso8601Show) import Data.Time.Format.ISO8601 (iso8601Show)
audit :: Text -> Text -> Maybe Text -> [(Text, Value)] -> IO () audit :: Text -> Text -> Maybe Text -> [(Text, Value)] -> IO ()
audit action result mUser extra = do audit action result mUser extra = do
now <- getCurrentTime now <- getCurrentTime
L8.putStrLn (encode (auditValue now action result mUser extra))
auditValue :: UTCTime -> Text -> Text -> Maybe Text -> [(Text, Value)] -> Value
auditValue now action result mUser extra =
let pairs = let pairs =
[ "ts" .= iso8601Show now [ "ts" .= iso8601Show now
, "action" .= action , "action" .= action
, "result" .= result , "result" .= result
] ]
<> maybe [] (\u -> ["user_id" .= u]) mUser <> maybe [] (\u -> ["user_id" .= u]) mUser
<> [ Key.fromText k .= v | (k, v) <- extra ] <>
L8.putStrLn (encode (object pairs)) [ Key.fromText k .= v
| (k, v) <- extra
, k `Set.member` allowedExtraFields
]
in object pairs
objectKeyFields :: Bool -> Text -> [(Text, Value)]
objectKeyFields includeObjectKey objectKey
| includeObjectKey = [("object_key", String objectKey)]
| otherwise = []
allowedExtraFields :: Set Text
allowedExtraFields = Set.fromList
[ "attempt"
, "attempts"
, "bytes"
, "dependency"
, "object_key"
, "operation"
, "operation_id"
, "reason"
, "root"
]

View file

@ -1,18 +1,32 @@
module Nuthatch.Config module Nuthatch.Config
( Config (..) ( Config (..)
, Secret
, secret
, revealSecret
, loadConfig , loadConfig
, parseRedisUri
, validateAbsoluteUri
) where ) where
import Control.Exception (SomeException, try)
import Control.Monad (when) import Control.Monad (when)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import Data.List (nub) import Data.List (nub)
import Data.Maybe (fromMaybe) import Data.List.NonEmpty (NonEmpty (..))
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import qualified Env
import Nuthatch.Streaming (maximumMultipartBytes) import Nuthatch.Streaming (maximumMultipartBytes)
import System.Environment (lookupEnv) import qualified Text.URI as URI
import Text.Read (readMaybe)
newtype Secret value = Secret value
secret :: value -> Secret value
secret = Secret
revealSecret :: Secret value -> value
revealSecret (Secret value) = value
data Config = Config data Config = Config
{ cfgListenHost :: String { cfgListenHost :: String
@ -20,7 +34,7 @@ data Config = Config
, cfgTlsCertPath :: FilePath , cfgTlsCertPath :: FilePath
, cfgTlsKeyPath :: FilePath , cfgTlsKeyPath :: FilePath
, cfgPublicBaseUrl :: Text , cfgPublicBaseUrl :: Text
, cfgJwtSecret :: Text , cfgJwtSecret :: Secret Text
, cfgSessionHours :: Integer , cfgSessionHours :: Integer
, cfgCookieName :: Text , cfgCookieName :: Text
, cfgCsrfCookieName :: Text , cfgCsrfCookieName :: Text
@ -28,19 +42,19 @@ data Config = Config
, cfgOidcIssuer :: Text , cfgOidcIssuer :: Text
, cfgOidcClientId :: Text , cfgOidcClientId :: Text
, cfgOidcAdditionalAudiences :: [Text] , cfgOidcAdditionalAudiences :: [Text]
, cfgOidcClientSecret :: Text , cfgOidcClientSecret :: Secret Text
, cfgOidcRedirectUri :: Text , cfgOidcRedirectUri :: Text
, cfgOidcTransactionMinutes :: Integer , cfgOidcTransactionMinutes :: Integer
, cfgOidcMaxIdTokenSeconds :: Integer , cfgOidcMaxIdTokenSeconds :: Integer
, cfgPgConn :: String , cfgPgConn :: Secret String
, cfgRedisHost :: String , cfgRedisHost :: String
, cfgRedisPort :: Int , cfgRedisPort :: Int
, cfgRedisDb :: Integer , cfgRedisDb :: Integer
, cfgS3Endpoint :: Text , cfgS3Endpoint :: Text
, cfgS3Region :: Text , cfgS3Region :: Text
, cfgS3Bucket :: Text , cfgS3Bucket :: Text
, cfgS3AccessKey :: Text , cfgS3AccessKey :: Secret Text
, cfgS3SecretKey :: Text , cfgS3SecretKey :: Secret Text
, cfgS3PathStyle :: Bool , cfgS3PathStyle :: Bool
, cfgPersonalQuotaBytes :: Integer , cfgPersonalQuotaBytes :: Integer
, cfgSharedQuotaBytes :: Integer , cfgSharedQuotaBytes :: Integer
@ -55,53 +69,62 @@ data Config = Config
, cfgRecoveryGraceSec :: Integer , cfgRecoveryGraceSec :: Integer
, cfgIdempotencyWindowSec :: Integer , cfgIdempotencyWindowSec :: Integer
, cfgObjectRequestTimeoutSec :: Integer , cfgObjectRequestTimeoutSec :: Integer
, cfgStartupAttempts :: Int
, cfgStartupDelayMs :: Int
, cfgAuditObjectKeys :: Bool
} }
loadConfig :: IO Config loadConfig :: IO Config
loadConfig = do loadConfig = do
listen <- fromMaybe "127.0.0.1:8080" <$> lookupEnv "NUTHATCH_LISTEN" listen <- envString "NUTHATCH_LISTEN" "127.0.0.1:8080"
let (host, portText) = splitHostPort listen let (host, portText) = splitHostPort listen
port <- requireRead "NUTHATCH_LISTEN port" portText port <- parseValue "NUTHATCH_LISTEN port" portText
tlsCertPath <- fromMaybe "/tls/tls.crt" <$> lookupEnv "NUTHATCH_TLS_CERT_PATH" when (T.null (T.pack host) || port <= 0 || port > 65535) $
tlsKeyPath <- fromMaybe "/tls/tls.key" <$> lookupEnv "NUTHATCH_TLS_KEY_PATH" fail "NUTHATCH_LISTEN must contain a host and port between 1 and 65535"
publicBase <- T.pack <$> requireEnv "NUTHATCH_PUBLIC_BASE_URL" tlsCertPath <- envString "NUTHATCH_TLS_CERT_PATH" "/tls/tls.crt"
when (not ("https://" `T.isPrefixOf` publicBase)) $ tlsKeyPath <- envString "NUTHATCH_TLS_KEY_PATH" "/tls/tls.key"
fail "NUTHATCH_PUBLIC_BASE_URL must use https" publicBase <- requireText "NUTHATCH_PUBLIC_BASE_URL"
jwtSecret <- T.pack <$> requireEnv "NUTHATCH_JWT_SECRET" >>= validateAbsoluteUri "NUTHATCH_PUBLIC_BASE_URL" ["https"]
when (BS.length (TE.encodeUtf8 jwtSecret) < 32) $ jwtSecretText <- requireTextSecret "NUTHATCH_JWT_SECRET"
when (BS.length (TE.encodeUtf8 jwtSecretText) < 32) $
fail "NUTHATCH_JWT_SECRET must be at least 32 bytes" fail "NUTHATCH_JWT_SECRET must be at least 32 bytes"
let jwtSecret = secret jwtSecretText
sessionHours <- envInteger "NUTHATCH_SESSION_HOURS" 12 sessionHours <- envInteger "NUTHATCH_SESSION_HOURS" 12
cookieName <- T.pack . fromMaybe "nuthatch_session" <$> lookupEnv "NUTHATCH_COOKIE_NAME" cookieName <- T.pack <$> envString "NUTHATCH_COOKIE_NAME" "nuthatch_session"
csrfCookie <- T.pack . fromMaybe "nuthatch_csrf" <$> lookupEnv "NUTHATCH_CSRF_COOKIE_NAME" csrfCookie <- T.pack <$> envString "NUTHATCH_CSRF_COOKIE_NAME" "nuthatch_csrf"
transactionCookie <- T.pack . fromMaybe "nuthatch_oidc" <$> lookupEnv "NUTHATCH_OIDC_TRANSACTION_COOKIE_NAME" transactionCookie <- T.pack <$>
oidcIssuer <- T.dropWhileEnd (== '/') . T.pack <$> requireEnv "NUTHATCH_OIDC_ISSUER" envString "NUTHATCH_OIDC_TRANSACTION_COOKIE_NAME" "nuthatch_oidc"
oidcClientId <- T.pack <$> requireEnv "NUTHATCH_OIDC_CLIENT_ID" oidcIssuerValue <- requireText "NUTHATCH_OIDC_ISSUER"
additionalAudienceValue <- lookupEnv "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES" oidcIssuer <- T.dropWhileEnd (== '/') <$>
let oidcAdditionalAudiences = validateAbsoluteUri "NUTHATCH_OIDC_ISSUER" ["http", "https"] oidcIssuerValue
case T.strip . T.pack <$> additionalAudienceValue of oidcClientId <- requireText "NUTHATCH_OIDC_CLIENT_ID"
Nothing -> [] additionalAudienceValue <- T.strip . T.pack <$>
Just "" -> [] envString "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES" ""
Just value -> map T.strip (T.splitOn "," value) let oidcAdditionalAudiences
| T.null additionalAudienceValue = []
| otherwise = map T.strip (T.splitOn "," additionalAudienceValue)
when when
( any T.null oidcAdditionalAudiences ( any T.null oidcAdditionalAudiences
|| oidcClientId `elem` oidcAdditionalAudiences || oidcClientId `elem` oidcAdditionalAudiences
|| length oidcAdditionalAudiences /= length (nub oidcAdditionalAudiences) || length oidcAdditionalAudiences /= length (nub oidcAdditionalAudiences)
) $ ) $
fail "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES must contain unique non-client values" fail "NUTHATCH_OIDC_ADDITIONAL_AUDIENCES must contain unique non-client values"
oidcClientSecret <- T.pack <$> requireEnv "NUTHATCH_OIDC_CLIENT_SECRET" oidcClientSecret <- secret <$> requireTextSecret "NUTHATCH_OIDC_CLIENT_SECRET"
oidcRedirectUri <- T.pack <$> requireEnv "NUTHATCH_OIDC_REDIRECT_URI" oidcRedirectUri <- requireText "NUTHATCH_OIDC_REDIRECT_URI"
>>= validateAbsoluteUri "NUTHATCH_OIDC_REDIRECT_URI" ["https"]
when (oidcRedirectUri /= publicBase <> "/oidc/callback") $ when (oidcRedirectUri /= publicBase <> "/oidc/callback") $
fail "NUTHATCH_OIDC_REDIRECT_URI must equal the public callback URL" fail "NUTHATCH_OIDC_REDIRECT_URI must equal the public callback URL"
oidcTransactionMinutes <- envInteger "NUTHATCH_OIDC_TRANSACTION_MINUTES" 5 oidcTransactionMinutes <- envInteger "NUTHATCH_OIDC_TRANSACTION_MINUTES" 5
oidcMaxIdTokenSeconds <- envInteger "NUTHATCH_OIDC_MAX_ID_TOKEN_SECONDS" 3600 oidcMaxIdTokenSeconds <- envInteger "NUTHATCH_OIDC_MAX_ID_TOKEN_SECONDS" 3600
pgConn <- requireEnv "NUTHATCH_PG_CONN" pgConn <- secret <$> requireStringSecret "NUTHATCH_PG_CONN"
redisUrl <- requireEnv "NUTHATCH_REDIS_URL" redisUrl <- requireText "NUTHATCH_REDIS_URL"
(redisHost, redisPort, redisDb) <- parseRedisUrl redisUrl (redisHost, redisPort, redisDb) <- parseRedisUri redisUrl
s3Endpoint <- T.pack <$> requireEnv "NUTHATCH_S3_ENDPOINT" s3Endpoint <- requireText "NUTHATCH_S3_ENDPOINT"
s3Region <- T.pack . fromMaybe "us-east-1" <$> lookupEnv "NUTHATCH_S3_REGION" >>= validateOriginUri "NUTHATCH_S3_ENDPOINT" ["http", "https"]
s3Bucket <- T.pack <$> requireEnv "NUTHATCH_S3_BUCKET" s3Region <- T.pack <$> envString "NUTHATCH_S3_REGION" "us-east-1"
s3Access <- T.pack <$> requireEnv "NUTHATCH_S3_ACCESS_KEY" s3Bucket <- requireText "NUTHATCH_S3_BUCKET"
s3Secret <- T.pack <$> requireEnv "NUTHATCH_S3_SECRET_KEY" s3Access <- secret <$> requireTextSecret "NUTHATCH_S3_ACCESS_KEY"
s3Secret <- secret <$> requireTextSecret "NUTHATCH_S3_SECRET_KEY"
pathStyle <- envBool "NUTHATCH_S3_PATH_STYLE" True pathStyle <- envBool "NUTHATCH_S3_PATH_STYLE" True
personalQuota <- envInteger "NUTHATCH_PERSONAL_QUOTA_BYTES" (1024 * 1024 * 1024) personalQuota <- envInteger "NUTHATCH_PERSONAL_QUOTA_BYTES" (1024 * 1024 * 1024)
sharedQuota <- envInteger "NUTHATCH_SHARED_QUOTA_BYTES" (10 * 1024 * 1024 * 1024) sharedQuota <- envInteger "NUTHATCH_SHARED_QUOTA_BYTES" (10 * 1024 * 1024 * 1024)
@ -115,27 +138,34 @@ loadConfig = do
when (throttleBurst <= 0 || throttleBurst > luaExactIntegerMax) $ when (throttleBurst <= 0 || throttleBurst > luaExactIntegerMax) $
fail "NUTHATCH_THROTTLE_BURST_BYTES exceeds the exact throttle range" fail "NUTHATCH_THROTTLE_BURST_BYTES exceeds the exact throttle range"
listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30 listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30
browsePageSize <- fromInteger <$> envInteger "NUTHATCH_BROWSE_PAGE_SIZE" 200 browsePageSize <- envBoundedInt "NUTHATCH_BROWSE_PAGE_SIZE" 200 1 1000
searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500 searchMax <- envBoundedInt "NUTHATCH_SEARCH_MAX_KEYS" 500 1 100000
searchTimeoutMs <- envInteger "NUTHATCH_SEARCH_TIMEOUT_MS" 2000 searchTimeoutMs <- envInteger "NUTHATCH_SEARCH_TIMEOUT_MS" 2000
deletePageSize <- fromInteger <$> envInteger "NUTHATCH_DELETE_PAGE_SIZE" 100 deletePageSize <- envBoundedInt "NUTHATCH_DELETE_PAGE_SIZE" 100 1 1000
recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90 recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90
idempotencyWindow <- envInteger "NUTHATCH_IDEMPOTENCY_WINDOW_SEC" 86400 idempotencyWindow <- envInteger "NUTHATCH_IDEMPOTENCY_WINDOW_SEC" 86400
objectRequestTimeout <- envInteger "NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC" 300 objectRequestTimeout <- envInteger "NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC" 300
startupAttempts <- envBoundedInt "NUTHATCH_STARTUP_ATTEMPTS" 60 1 1000
startupDelayMs <- envBoundedInt "NUTHATCH_STARTUP_DELAY_MS" 1000 1 60000
auditObjectKeys <- envBool "NUTHATCH_AUDIT_OBJECT_KEYS" False
when (recoveryGrace <= 0) $ when (recoveryGrace <= 0) $
fail "NUTHATCH_RECOVERY_GRACE_SEC must be positive" fail "NUTHATCH_RECOVERY_GRACE_SEC must be positive"
when (idempotencyWindow <= 0) $ when (idempotencyWindow <= 0) $
fail "NUTHATCH_IDEMPOTENCY_WINDOW_SEC must be positive" fail "NUTHATCH_IDEMPOTENCY_WINDOW_SEC must be positive"
when (objectRequestTimeout <= 0) $ when (objectRequestTimeout <= 0) $
fail "NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC must be positive" fail "NUTHATCH_OBJECT_REQUEST_TIMEOUT_SEC must be positive"
when (browsePageSize <= 0 || browsePageSize > 1000) $ when (sessionHours <= 0 || sessionHours > 168) $
fail "NUTHATCH_BROWSE_PAGE_SIZE must be between 1 and 1000" fail "NUTHATCH_SESSION_HOURS must be between 1 and 168"
when (searchMax <= 0 || searchMax > 100000) $ when (oidcTransactionMinutes <= 0 || oidcTransactionMinutes > 60) $
fail "NUTHATCH_SEARCH_MAX_KEYS must be between 1 and 100000" fail "NUTHATCH_OIDC_TRANSACTION_MINUTES must be between 1 and 60"
when (oidcMaxIdTokenSeconds <= 0 || oidcMaxIdTokenSeconds > 86400) $
fail "NUTHATCH_OIDC_MAX_ID_TOKEN_SECONDS must be between 1 and 86400"
when (personalQuota <= 0 || sharedQuota <= 0) $
fail "configured quotas must be positive"
when (listTtl <= 0 || listTtl > 86400) $
fail "NUTHATCH_LIST_CACHE_TTL_SEC must be between 1 and 86400"
when (searchTimeoutMs <= 0 || searchTimeoutMs > 60000) $ when (searchTimeoutMs <= 0 || searchTimeoutMs > 60000) $
fail "NUTHATCH_SEARCH_TIMEOUT_MS must be between 1 and 60000" fail "NUTHATCH_SEARCH_TIMEOUT_MS must be between 1 and 60000"
when (deletePageSize <= 0 || deletePageSize > 1000) $
fail "NUTHATCH_DELETE_PAGE_SIZE must be between 1 and 1000"
pure Config pure Config
{ cfgListenHost = host { cfgListenHost = host
, cfgListenPort = port , cfgListenPort = port
@ -177,6 +207,9 @@ loadConfig = do
, cfgRecoveryGraceSec = recoveryGrace , cfgRecoveryGraceSec = recoveryGrace
, cfgIdempotencyWindowSec = idempotencyWindow , cfgIdempotencyWindowSec = idempotencyWindow
, cfgObjectRequestTimeoutSec = objectRequestTimeout , cfgObjectRequestTimeoutSec = objectRequestTimeout
, cfgStartupAttempts = startupAttempts
, cfgStartupDelayMs = startupDelayMs
, cfgAuditObjectKeys = auditObjectKeys
} }
luaExactIntegerMax :: Integer luaExactIntegerMax :: Integer
@ -184,56 +217,117 @@ luaExactIntegerMax = 9007199254740991
splitHostPort :: String -> (String, String) splitHostPort :: String -> (String, String)
splitHostPort s = splitHostPort s =
case break (== ':') s of case span (/= ':') s of
(h, ':' : p) -> (h, p) (h, ':' : p) | not (null h) && not (null p) -> (h, p)
_ -> ("127.0.0.1", "8080") _ -> ("", "")
requireEnv :: String -> IO String
requireEnv name = do
value <- lookupEnv name
case value of
Just present | not (null present) -> pure present
_ -> fail $ name <> " is required"
envInteger :: String -> Integer -> IO Integer envInteger :: String -> Integer -> IO Integer
envInteger name def = do envInteger name defaultValue =
m <- lookupEnv name Env.parse configInfo (Env.var Env.auto name (Env.def defaultValue))
case m of
Nothing -> pure def envBoundedInt :: String -> Integer -> Integer -> Integer -> IO Int
Just v -> requireRead name v envBoundedInt name defaultValue minimumValue maximumValue = do
value <- envInteger name defaultValue
when (value < minimumValue || value > maximumValue) $
fail
(name <> " must be between "
<> show minimumValue <> " and " <> show maximumValue)
pure (fromInteger value)
envBool :: String -> Bool -> IO Bool envBool :: String -> Bool -> IO Bool
envBool name def = do envBool name defaultValue =
m <- lookupEnv name Env.parse configInfo $
case fmap T.toLower (T.pack <$> m) of Env.var (Env.eitherReader parseBoolean) name (Env.def defaultValue)
Nothing -> pure def where
Just "1" -> pure True parseBoolean value =
Just "true" -> pure True case map toLowerAscii value of
Just "yes" -> pure True "1" -> Right True
Just "0" -> pure False "true" -> Right True
Just "false" -> pure False "yes" -> Right True
Just "no" -> pure False "0" -> Right False
Just other -> fail $ name <> ": invalid boolean " <> T.unpack other "false" -> Right False
"no" -> Right False
_ -> Left "expected true or false"
toLowerAscii c
| c >= 'A' && c <= 'Z' = toEnum (fromEnum c + 32)
| otherwise = c
requireRead :: (Read a) => String -> String -> IO a envString :: String -> String -> IO String
requireRead name v = envString name defaultValue =
case readMaybe v of Env.parse configInfo (Env.var Env.str name (Env.def defaultValue))
Just x -> pure x
Nothing -> fail $ name <> ": cannot parse " <> v
parseRedisUrl :: String -> IO (String, Int, Integer) requireText :: String -> IO Text
parseRedisUrl url = requireText name = T.pack <$>
case T.stripPrefix "redis://" (T.pack url) of Env.parse configInfo (Env.var Env.nonempty name mempty)
Nothing -> fail "NUTHATCH_REDIS_URL must start with redis://"
Just rest -> do requireTextSecret :: String -> IO Text
let (hostPort, dbPart) = requireTextSecret name = T.pack <$>
case T.breakOn "/" rest of Env.parse configInfo (Env.sensitive (Env.var Env.nonempty name mempty))
(hp, d) | T.null d -> (hp, "/0")
(hp, d) -> (hp, d) requireStringSecret :: String -> IO String
(hostText, portText) = requireStringSecret name =
case T.breakOn ":" hostPort of Env.parse configInfo (Env.sensitive (Env.var Env.nonempty name mempty))
(h, p) | T.null p -> (h, "6379")
(h, p) -> (h, T.drop 1 p) parseValue :: Read value => String -> String -> IO value
port <- requireRead "redis port" (T.unpack portText) parseValue name value =
db <- requireRead "redis db" (T.unpack (T.drop 1 dbPart)) case reads value of
pure (T.unpack hostText, port, db) [(parsed, "")] -> pure parsed
_ -> fail (name <> " is invalid")
configInfo :: Env.Info Env.Error -> Env.Info Env.Error
configInfo = Env.header "Nuthatch configuration"
validateAbsoluteUri :: String -> [Text] -> Text -> IO Text
validateAbsoluteUri name allowedSchemes value = do
parsed <- try (URI.mkURI value) :: IO (Either SomeException URI.URI)
uri <- either (const (fail (name <> " is not a valid URI"))) pure parsed
scheme <- maybe (fail (name <> " must be absolute"))
(pure . T.toLower . URI.unRText)
(URI.uriScheme uri)
when (scheme `notElem` allowedSchemes) $
fail (name <> " uses an unsupported scheme")
authority <- either
(const (fail (name <> " must contain an authority")))
pure
(URI.uriAuthority uri)
when (URI.authUserInfo authority /= Nothing) $
fail (name <> " must not contain credentials")
when (T.null (URI.unRText (URI.authHost authority))) $
fail (name <> " must contain a host")
pure (URI.render uri)
validateOriginUri :: String -> [Text] -> Text -> IO Text
validateOriginUri name allowedSchemes value = do
rendered <- validateAbsoluteUri name allowedSchemes value
parsed <- URI.mkURI rendered
when
(URI.uriPath parsed /= Nothing
|| not (null (URI.uriQuery parsed))
|| URI.uriFragment parsed /= Nothing) $
fail (name <> " must be an origin without path, query, or fragment")
pure rendered
parseRedisUri :: Text -> IO (String, Int, Integer)
parseRedisUri value = do
rendered <- validateAbsoluteUri "NUTHATCH_REDIS_URL" ["redis"] value
parsed <- URI.mkURI rendered
authority <- either
(const (fail "NUTHATCH_REDIS_URL must contain an authority"))
pure
(URI.uriAuthority parsed)
when (not (null (URI.uriQuery parsed)) || URI.uriFragment parsed /= Nothing) $
fail "NUTHATCH_REDIS_URL must not contain a query or fragment"
database <- case URI.uriPath parsed of
Nothing -> pure 0
Just (_, segment :| []) ->
parseValue "NUTHATCH_REDIS_URL database" (T.unpack (URI.unRText segment))
_ -> fail "NUTHATCH_REDIS_URL must contain at most one database path segment"
let port = maybe 6379 fromIntegral (URI.authPort authority)
when (database < 0 || database > 15) $
fail "NUTHATCH_REDIS_URL database must be between 0 and 15"
let renderedHost = URI.unRText (URI.authHost authority)
connectionHost =
fromMaybeText renderedHost (T.stripPrefix "[" renderedHost >>= T.stripSuffix "]")
pure (T.unpack connectionHost, port, database)
where
fromMaybeText fallback = maybe fallback id

View file

@ -12,7 +12,8 @@ module Nuthatch.Db
, executeMany , executeMany
) where ) where
import Control.Exception (bracket, throwIO) import Control.Exception (bracket, finally, throwIO)
import Control.Monad (void)
import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Char8 as BS8
import Data.Pool import Data.Pool
( Pool ( Pool
@ -22,7 +23,12 @@ import Data.Pool
, setNumStripes , setNumStripes
, withResource , withResource
) )
import Database.PostgreSQL.Simple (Connection, close, connectPostgreSQL) import Database.PostgreSQL.Simple
( Connection
, Only
, close
, connectPostgreSQL
)
import qualified Database.PostgreSQL.Simple as PG import qualified Database.PostgreSQL.Simple as PG
import Database.PostgreSQL.Simple.Migration import Database.PostgreSQL.Simple.Migration
( MigrationCommand (..) ( MigrationCommand (..)
@ -31,14 +37,14 @@ import Database.PostgreSQL.Simple.Migration
, runMigration , runMigration
) )
import qualified Database.PostgreSQL.Simple.Transaction as PGT import qualified Database.PostgreSQL.Simple.Transaction as PGT
import Nuthatch.Config (Config (..)) import Nuthatch.Config (Config (..), revealSecret)
import Nuthatch.Types import Nuthatch.Types
type Db = Pool Connection type Db = Pool Connection
withDb :: Config -> (Db -> IO a) -> IO a withDb :: Config -> (Db -> IO a) -> IO a
withDb cfg action = do withDb cfg action = do
let mk = connectPostgreSQL (BS8.pack (cfgPgConn cfg)) let mk = connectPostgreSQL (BS8.pack (revealSecret (cfgPgConn cfg)))
poolCfg = poolCfg =
setNumStripes (Just 1) $ setNumStripes (Just 1) $
defaultPoolConfig mk close 30 10 defaultPoolConfig mk close 30 10
@ -46,10 +52,20 @@ withDb cfg action = do
migrate :: Db -> FilePath -> Config -> IO () migrate :: Db -> FilePath -> Config -> IO ()
migrate db migrationDirectory cfg = withResource db $ \conn -> do migrate db migrationDirectory cfg = withResource db $ \conn -> do
result <- runMigration conn defaultOptions $ void
MigrationInitialization (PG.query_ conn
<> MigrationDirectory migrationDirectory "SELECT pg_advisory_lock(564741953576419031) IS NULL"
<> MigrationValidation (MigrationDirectory migrationDirectory) :: IO [Only Bool])
result <-
runMigration conn defaultOptions
( MigrationInitialization
<> MigrationDirectory migrationDirectory
<> MigrationValidation (MigrationDirectory migrationDirectory)
)
`finally` void
(PG.query_ conn
"SELECT pg_advisory_unlock(564741953576419031)"
:: IO [Only Bool])
case result of case result of
MigrationSuccess -> MigrationSuccess ->
PGT.withTransaction conn $ PGT.withTransaction conn $

View file

@ -66,14 +66,14 @@ import Network.HTTP.Simple
, setRequestMethod , setRequestMethod
) )
import Network.HTTP.Types.URI (renderQuery) import Network.HTTP.Types.URI (renderQuery)
import Nuthatch.Config (Config (..)) import Nuthatch.Config (Config (..), revealSecret)
import System.Entropy (getEntropy) import System.Entropy (getEntropy)
data AuthorizationRequest = AuthorizationRequest data AuthorizationRequest = AuthorizationRequest
{ authorizationLocation :: Text { authorizationLocation :: Text
, authorizationTransaction :: Text , authorizationTransaction :: Text
} }
deriving stock (Eq, Show) deriving stock (Eq)
data OidcIdentity = OidcIdentity data OidcIdentity = OidcIdentity
{ identityIssuer :: Text { identityIssuer :: Text
@ -115,7 +115,7 @@ data TransactionClaims = TransactionClaims
, transactionRedirectUri :: Text , transactionRedirectUri :: Text
, transactionReturnTarget :: Text , transactionReturnTarget :: Text
} }
deriving stock (Eq, Show) deriving stock (Eq)
instance HasClaimsSet TransactionClaims where instance HasClaimsSet TransactionClaims where
claimsSet update claims = claimsSet update claims =
@ -157,7 +157,7 @@ data IdTokenClaims = IdTokenClaims
, idTokenName :: Maybe Text , idTokenName :: Maybe Text
, idTokenPreferred :: Maybe Text , idTokenPreferred :: Maybe Text
} }
deriving stock (Eq, Show) deriving stock (Eq)
instance HasClaimsSet IdTokenClaims where instance HasClaimsSet IdTokenClaims where
claimsSet update claims = claimsSet update claims =
@ -281,7 +281,7 @@ exchangeCode cfg discovery transaction code = do
let request = let request =
applyBasicAuth applyBasicAuth
(TE.encodeUtf8 (cfgOidcClientId cfg)) (TE.encodeUtf8 (cfgOidcClientId cfg))
(TE.encodeUtf8 (cfgOidcClientSecret cfg)) $ (TE.encodeUtf8 (revealSecret (cfgOidcClientSecret cfg))) $
setRequestMethod "POST" $ setRequestMethod "POST" $
setRequestBodyURLEncoded setRequestBodyURLEncoded
[ ("grant_type", "authorization_code") [ ("grant_type", "authorization_code")
@ -410,7 +410,7 @@ mintTransaction cfg state nonce verifier = do
, transactionRedirectUri = cfgOidcRedirectUri cfg , transactionRedirectUri = cfgOidcRedirectUri cfg
, transactionReturnTarget = "/" , transactionReturnTarget = "/"
} }
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg)) key = fromOctets (TE.encodeUtf8 (revealSecret (cfgJwtSecret cfg)))
result <- runJOSE $ result <- runJOSE $
encodeCompact <$> signJWT key (newJWSHeaderProtected HS256) claims encodeCompact <$> signJWT key (newJWSHeaderProtected HS256) claims
pure $ case result of pure $ case result of
@ -419,7 +419,7 @@ mintTransaction cfg state nonce verifier = do
verifyTransaction :: Config -> Text -> IO (Either Text TransactionClaims) verifyTransaction :: Config -> Text -> IO (Either Text TransactionClaims)
verifyTransaction cfg compact = do verifyTransaction cfg compact = do
let key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg)) let key = fromOctets (TE.encodeUtf8 (revealSecret (cfgJwtSecret cfg)))
settings = settings =
defaultJWTValidationSettings (== "nuthatch-oidc-callback") defaultJWTValidationSettings (== "nuthatch-oidc-callback")
& issuerPredicate .~ (== "nuthatch") & issuerPredicate .~ (== "nuthatch")

View file

@ -3,6 +3,7 @@ module Nuthatch.Redis
, withRedis , withRedis
, cacheGet , cacheGet
, cacheSet , cacheSet
, cacheGeneration
, cacheInvalidatePrefixes , cacheInvalidatePrefixes
, throttleCheck , throttleCheck
, throttleTake , throttleTake
@ -18,10 +19,11 @@ import Database.Redis
( ConnectInfo (..) ( ConnectInfo (..)
, Connection , Connection
, Reply , Reply
, checkedConnect , connect
, defaultConnectInfo , defaultConnectInfo
, del , del
, get , get
, incr
, runRedis , runRedis
, setex , setex
) )
@ -34,7 +36,7 @@ type Redis = Connection
withRedis :: Config -> (Redis -> IO a) -> IO a withRedis :: Config -> (Redis -> IO a) -> IO a
withRedis cfg action = do withRedis cfg action = do
conn <- checkedConnect defaultConnectInfo conn <- connect defaultConnectInfo
{ connectHost = cfgRedisHost cfg { connectHost = cfgRedisHost cfg
, connectPort = R.PortNumber (fromIntegral (cfgRedisPort cfg)) , connectPort = R.PortNumber (fromIntegral (cfgRedisPort cfg))
, connectDatabase = cfgRedisDb cfg , connectDatabase = cfgRedisDb cfg
@ -56,6 +58,16 @@ cacheSet conn key ttl value = do
:: IO (Either SomeException (Either Reply R.Status)) :: IO (Either SomeException (Either Reply R.Status))
pure () pure ()
cacheGeneration :: Redis -> UserId -> Root -> IO (Maybe ByteString)
cacheGeneration conn uid root = do
let key = TE.encodeUtf8 (cacheGenerationKey uid root)
result <- trySynchronous (runRedis conn (get key))
:: IO (Either SomeException (Either Reply (Maybe ByteString)))
pure $ case result of
Right (Right Nothing) -> Just "0"
Right (Right value) -> value
_ -> Nothing
cacheInvalidatePrefixes :: Redis -> UserId -> Root -> [Text] -> IO () cacheInvalidatePrefixes :: Redis -> UserId -> Root -> [Text] -> IO ()
cacheInvalidatePrefixes conn uid root prefixes = do cacheInvalidatePrefixes conn uid root prefixes = do
let ks = let ks =
@ -69,6 +81,17 @@ cacheInvalidatePrefixes conn uid root prefixes = do
_ <- trySynchronous (runRedis conn (del ks)) _ <- trySynchronous (runRedis conn (del ks))
:: IO (Either SomeException (Either Reply Integer)) :: IO (Either SomeException (Either Reply Integer))
pure () pure ()
_ <- trySynchronous $
runRedis conn (incr (TE.encodeUtf8 (cacheGenerationKey uid root)))
:: IO (Either SomeException (Either Reply Integer))
pure ()
cacheGenerationKey :: UserId -> Root -> Text
cacheGenerationKey uid root =
"nuthatch:list-generation:"
<> case root of
Personal -> unUserId uid <> ":personal"
Shared -> "shared"
throttleCheck :: Redis -> Config -> UserId -> Integer -> IO (Either Text Bool) throttleCheck :: Redis -> Config -> UserId -> Integer -> IO (Either Text Bool)
throttleCheck conn cfg uid bytes = throttleCheck conn cfg uid bytes =

45
src/Nuthatch/Runtime.hs Normal file
View file

@ -0,0 +1,45 @@
module Nuthatch.Runtime
( retryStartup
) where
import Control.Exception (SomeException)
import Control.Retry
( RetryStatus (..)
, constantDelay
, limitRetries
, retrying
)
import Data.Aeson (Value (String), toJSON)
import Data.Either (isLeft)
import Data.Text (Text)
import qualified Data.Text as T
import Nuthatch.Audit (audit)
import Nuthatch.Recovery (trySynchronous)
retryStartup :: Int -> Int -> Text -> IO value -> IO value
retryStartup attempts delayMs dependency action = do
result <- retrying policy shouldRetry $ \_ ->
tryStartup action
case result of
Right value -> pure value
Left _ -> do
audit "dependency_startup" "exhausted" Nothing
[ ("dependency", String dependency)
, ("attempts", toJSON attempts)
]
fail ("startup dependency unavailable: " <> T.unpack dependency)
where
policy =
limitRetries (max 0 (attempts - 1))
<> constantDelay (max 1 delayMs * 1000)
shouldRetry status outcome = do
if isLeft outcome
then audit "dependency_startup" "retry" Nothing
[ ("dependency", String dependency)
, ("attempt", toJSON (rsIterNumber status + 1))
]
else pure ()
pure (isLeft outcome)
tryStartup :: IO value -> IO (Either SomeException value)
tryStartup = trySynchronous

View file

@ -61,7 +61,7 @@ import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Client.TLS (tlsManagerSettings) import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types.Status (statusCode) import Network.HTTP.Types.Status (statusCode)
import Network.URI (URI (..), URIAuth (..), parseURI) import Network.URI (URI (..), URIAuth (..), parseURI)
import Nuthatch.Config (Config (..)) import Nuthatch.Config (Config (..), revealSecret)
import Nuthatch.Streaming import Nuthatch.Streaming
( BandwidthError (..) ( BandwidthError (..)
, UploadStreamError (..) , UploadStreamError (..)
@ -116,8 +116,8 @@ newS3 :: Config -> IO S3
newS3 cfg = do newS3 cfg = do
(protocol, endpointHost, endpointPort) <- parseEndpoint (cfgS3Endpoint cfg) (protocol, endpointHost, endpointPort) <- parseEndpoint (cfgS3Endpoint cfg)
credentials <- Aws.makeCredentials credentials <- Aws.makeCredentials
(TE.encodeUtf8 (cfgS3AccessKey cfg)) (TE.encodeUtf8 (revealSecret (cfgS3AccessKey cfg)))
(TE.encodeUtf8 (cfgS3SecretKey cfg)) (TE.encodeUtf8 (revealSecret (cfgS3SecretKey cfg)))
let defaultPort = case protocol of let defaultPort = case protocol of
AwsCore.HTTP -> 80 AwsCore.HTTP -> 80
AwsCore.HTTPS -> 443 AwsCore.HTTPS -> 443

View file

@ -45,7 +45,7 @@ import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import Data.Time.Clock (addUTCTime, getCurrentTime) import Data.Time.Clock (addUTCTime, getCurrentTime)
import Nuthatch.Config (Config (..)) import Nuthatch.Config (Config (..), revealSecret)
import Nuthatch.Paths (encodeUserId) import Nuthatch.Paths (encodeUserId)
import Nuthatch.Types import Nuthatch.Types
import System.Entropy (getEntropy) import System.Entropy (getEntropy)
@ -108,7 +108,7 @@ mintSession cfg issuer subject display = do
, oidcSubject = subject , oidcSubject = subject
, displayName = display , displayName = display
} }
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg)) key = fromOctets (TE.encodeUtf8 (revealSecret (cfgJwtSecret cfg)))
header = newJWSHeaderProtected HS256 header = newJWSHeaderProtected HS256
encodeCompact <$> signJWT key header claims encodeCompact <$> signJWT key header claims
pure $ case result of pure $ case result of
@ -126,7 +126,7 @@ verifySession cfg (SessionToken token) = do
defaultJWTValidationSettings (== applicationAudience) defaultJWTValidationSettings (== applicationAudience)
& issuerPredicate .~ (== applicationIssuer) & issuerPredicate .~ (== applicationIssuer)
& algorithms .~ singleton HS256 & algorithms .~ singleton HS256
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg)) key = fromOctets (TE.encodeUtf8 (revealSecret (cfgJwtSecret cfg)))
verifyJWT settings key (jwt :: SignedJWT) verifyJWT settings key (jwt :: SignedJWT)
pure $ case result of pure $ case result of
Left (_ :: JWTError) -> Left "session token validation failed" Left (_ :: JWTError) -> Left "session token validation failed"

View file

@ -125,10 +125,10 @@ instance FromJSON OperationId where
either (fail . T.unpack) pure . validateOperationId either (fail . T.unpack) pure . validateOperationId
newtype SessionToken = SessionToken { unSessionToken :: Text } newtype SessionToken = SessionToken { unSessionToken :: Text }
deriving stock (Eq, Ord, Show) deriving stock (Eq, Ord)
newtype CsrfToken = CsrfToken { unCsrfToken :: Text } newtype CsrfToken = CsrfToken { unCsrfToken :: Text }
deriving stock (Eq, Ord, Show) deriving stock (Eq, Ord)
deriving newtype (ToJSON) deriving newtype (ToJSON)
instance FromJSON CsrfToken where instance FromJSON CsrfToken where

View file

@ -11,7 +11,12 @@ import Data.List (sort)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import Database.PostgreSQL.Simple (Only (..)) import Database.PostgreSQL.Simple (Only (..))
import Nuthatch.Config (Config (..), loadConfig) import Nuthatch.Config
( Config (..)
, loadConfig
, revealSecret
, secret
)
import Nuthatch.Db import Nuthatch.Db
( Db ( Db
, configurePoolLimit , configurePoolLimit
@ -356,7 +361,9 @@ verifyStorage config store = do
missing <- getObjectBytes store (ObjectKey "shared/missing") missing <- getObjectBytes store (ObjectKey "shared/missing")
assertEqual "typed not-found error" (Left S3NotFound) missing assertEqual "typed not-found error" (Left S3NotFound) missing
deniedStore <- newS3 config deniedStore <- newS3 config
{ cfgS3SecretKey = cfgS3SecretKey config <> "-invalid" } { cfgS3SecretKey =
secret (revealSecret (cfgS3SecretKey config) <> "-invalid")
}
denied <- listObjects deniedStore "" Nothing Nothing 1 denied <- listObjects deniedStore "" Nothing Nothing 1
case denied of case denied of
Left (S3ServiceError code) -> Left (S3ServiceError code) ->

127
test/RuntimeProbe.hs Normal file
View file

@ -0,0 +1,127 @@
module Main (main) where
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, try)
import Control.Monad (unless)
import Data.Foldable (traverse_)
import Nuthatch.Config (Config (..), loadConfig)
import Nuthatch.Redis
import Nuthatch.Types
import System.Environment
( getArgs
, lookupEnv
, setEnv
, unsetEnv
)
main :: IO ()
main = do
mode <- getArgs
case mode of
["config"] -> verifyConfiguration
["available"] -> withConfiguration $ \config ->
withRedis config (verifyAvailable config)
["unavailable"] -> withConfiguration $ \config ->
withRedis config (verifyUnavailable config)
_ -> fail "expected config, available, or unavailable mode"
withConfiguration :: (Config -> IO ()) -> IO ()
withConfiguration action = loadConfig >>= action
verifyConfiguration :: IO ()
verifyConfiguration = do
baseValues <- traverse
(\name -> (name,) <$> lookupEnv name)
[ "NUTHATCH_JWT_SECRET"
, "NUTHATCH_OIDC_CLIENT_SECRET"
, "NUTHATCH_PG_CONN"
, "NUTHATCH_S3_ACCESS_KEY"
, "NUTHATCH_S3_SECRET_KEY"
, "NUTHATCH_S3_ENDPOINT"
, "NUTHATCH_BROWSE_PAGE_SIZE"
, "NUTHATCH_PUBLIC_BASE_URL"
, "NUTHATCH_REDIS_URL"
]
let load changes = do
traverse_ (uncurry setVariable) baseValues
traverse_ (uncurry setVariable) changes
loadConfig
expectAccepted (load []) "valid configuration"
expectRejected
(load [("NUTHATCH_PG_CONN", Nothing)])
"missing required configuration"
expectRejected
(load [("NUTHATCH_S3_ENDPOINT", Just "not a URI")])
"malformed URI configuration"
expectRejected
(load [("NUTHATCH_S3_ENDPOINT", Just "https://store.invalid/path")])
"non-origin S3 configuration"
expectRejected
(load
[("NUTHATCH_BROWSE_PAGE_SIZE", Just "18446744073709551617")])
"out-of-range configuration"
expectRejected
(load
[ ( "NUTHATCH_PUBLIC_BASE_URL"
, Just "https://user:[email protected]"
)
])
"credential-bearing URI configuration"
expectAccepted
(load [("NUTHATCH_REDIS_URL", Just "redis://[::1]:6379/0")])
"IPv6 URI configuration"
putStrLn "runtime configuration cases passed"
expectAccepted :: IO Config -> String -> IO ()
expectAccepted load label = do
result <- try load :: IO (Either SomeException Config)
case result of
Left _ -> fail (label <> " was rejected")
Right _ -> pure ()
expectRejected :: IO Config -> String -> IO ()
expectRejected load label = do
result <- try load :: IO (Either SomeException Config)
case result of
Left _ -> pure ()
Right _ -> fail (label <> " was accepted")
setVariable :: String -> Maybe String -> IO ()
setVariable name = maybe (unsetEnv name) (setEnv name)
verifyAvailable :: Config -> Redis -> IO ()
verifyAvailable config redis = do
cacheSet redis "nuthatch:wp12:ttl" (cfgListCacheTtlSec config) "cached"
cacheGet redis "nuthatch:wp12:ttl" >>= requireEqual (Just "cached")
threadDelay (fromInteger ((cfgListCacheTtlSec config + 1) * 1000000))
cacheGet redis "nuthatch:wp12:ttl" >>= requireEqual Nothing
let userA = UserId "runtime-user-a"
userB = UserId "runtime-user-b"
sharedBeforeA <- cacheGeneration redis userA Shared
sharedBeforeB <- cacheGeneration redis userB Shared
requireEqual sharedBeforeA sharedBeforeB
cacheInvalidatePrefixes redis userA Shared [""]
sharedAfterB <- cacheGeneration redis userB Shared
unless (sharedAfterB /= sharedBeforeB) $
fail "shared cache generation did not change across users"
personalBeforeB <- cacheGeneration redis userB Personal
cacheInvalidatePrefixes redis userA Personal [""]
personalAfterB <- cacheGeneration redis userB Personal
requireEqual personalBeforeB personalAfterB
putStrLn "runtime cache coherence passed"
verifyUnavailable :: Config -> Redis -> IO ()
verifyUnavailable config redis = do
cacheGet redis "nuthatch:wp12:missing" >>= requireEqual Nothing
cacheSet redis "nuthatch:wp12:missing" 1 "ignored"
cacheInvalidatePrefixes redis (UserId "runtime-user") Shared [""]
throttled <- throttleCheck redis config (UserId "runtime-user") 1
case throttled of
Left _ -> pure ()
Right _ -> fail "transfer throttle did not fail closed"
putStrLn "runtime Redis degradation passed"
requireEqual :: (Eq value, Show value) => value -> value -> IO ()
requireEqual expected actual =
unless (actual == expected) $
fail ("expected " <> show expected <> ", got " <> show actual)

View file

@ -80,13 +80,19 @@ import Network.Wai.Test
, srequest , srequest
) )
import Nuthatch.Api import Nuthatch.Api
import Nuthatch.Audit (auditValue, objectKeyFields)
import Nuthatch.App import Nuthatch.App
( authenticationHandler ( authenticationHandler
, collectKeysBoundedWith , collectKeysBoundedWith
, loginResponse , loginResponse
, utf8Boundary , utf8Boundary
) )
import Nuthatch.Config (Config (..)) import Nuthatch.Config
( Config (..)
, parseRedisUri
, secret
, validateAbsoluteUri
)
import Nuthatch.Oidc (OidcIdentity (..), verifyIdToken) import Nuthatch.Oidc (OidcIdentity (..), verifyIdToken)
import Nuthatch.Paths import Nuthatch.Paths
( buildObjectKey ( buildObjectKey
@ -114,6 +120,7 @@ import Nuthatch.Recovery
, withLinkedWorker , withLinkedWorker
) )
import Nuthatch.S3 (ListPage (..)) import Nuthatch.S3 (ListPage (..))
import Nuthatch.Runtime (retryStartup)
import Nuthatch.Types import Nuthatch.Types
import Nuthatch.Session (mintSession) import Nuthatch.Session (mintSession)
import Nuthatch.Streaming import Nuthatch.Streaming
@ -147,10 +154,68 @@ tests = testGroup "nuthatch-unit"
, recoveryTests , recoveryTests
, streamingTests , streamingTests
, productBehaviorTests , productBehaviorTests
, runtimeTests
, oidcTests , oidcTests
, apiTests , apiTests
] ]
runtimeTests :: TestTree
runtimeTests = testGroup "runtime"
[ testCase "URI parsing accepts IPv6 and rejects credentials" $ do
parseRedisUri "redis://[2001:db8::1]:6380/4"
>>= (@?= ("2001:db8::1", 6380, 4))
redisCredential <- try
(parseRedisUri "redis://user:[email protected]/0")
:: IO (Either SomeException (String, Int, Integer))
assertBool "credential-bearing Redis URI rejected" (isLeft redisCredential)
endpointCredential <- try
(validateAbsoluteUri
"endpoint"
["https"]
"https://user:[email protected]")
:: IO (Either SomeException Text)
assertBool "credential-bearing endpoint rejected"
(isLeft endpointCredential)
, testCase "startup retry is bounded and succeeds within its budget" $ do
calls <- newIORef (0 :: Int)
result <- retryStartup 3 1 "synthetic" $ do
modifyIORef' calls (+ 1)
count <- readIORef calls
if count < 3 then fail "not ready" else pure count
result @?= 3
readIORef calls >>= (@?= 3)
, testCase "startup retry exhausts its configured budget" $ do
calls <- newIORef (0 :: Int)
result <- try $
retryStartup 2 1 "synthetic" $ do
modifyIORef' calls (+ 1)
fail "not ready"
:: IO (Either SomeException ())
assertBool "retry exhaustion fails startup" (isLeft result)
readIORef calls >>= (@?= 2)
, testCase "audit records retain required fields and drop prohibited fields" $ do
now <- getCurrentTime
let record = auditValue now "put" "rejected" (Just "user")
[ ("root", A.String "shared")
, ("authorization", A.String "Bearer prohibited")
, ("token", A.String "prohibited")
]
case record of
A.Object fields -> do
assertBool "required timestamp present" (KM.member "ts" fields)
KM.lookup "action" fields @?= Just (A.String "put")
KM.lookup "result" fields @?= Just (A.String "rejected")
KM.lookup "user_id" fields @?= Just (A.String "user")
KM.lookup "root" fields @?= Just (A.String "shared")
assertBool "authorization omitted" (not (KM.member "authorization" fields))
assertBool "token omitted" (not (KM.member "token" fields))
_ -> assertFailure "audit record was not an object"
, testCase "audit object keys follow the configured privacy policy" $ do
objectKeyFields False "private/name" @?= []
objectKeyFields True "private/name"
@?= [("object_key", A.String "private/name")]
]
productBehaviorTests :: TestTree productBehaviorTests :: TestTree
productBehaviorTests = testGroup "product behavior" productBehaviorTests = testGroup "product behavior"
[ testCase "search deadline cancels an in-flight listing" $ do [ testCase "search deadline cancels an in-flight listing" $ do
@ -939,7 +1004,7 @@ testConfig = Config
, cfgTlsCertPath = "/tls/tls.crt" , cfgTlsCertPath = "/tls/tls.crt"
, cfgTlsKeyPath = "/tls/tls.key" , cfgTlsKeyPath = "/tls/tls.key"
, cfgPublicBaseUrl = "https://nuthatch.example" , cfgPublicBaseUrl = "https://nuthatch.example"
, cfgJwtSecret = "unit-test-session-secret-material-32" , cfgJwtSecret = secret "unit-test-session-secret-material-32"
, cfgSessionHours = 1 , cfgSessionHours = 1
, cfgCookieName = "nuthatch_session" , cfgCookieName = "nuthatch_session"
, cfgCsrfCookieName = "nuthatch_csrf" , cfgCsrfCookieName = "nuthatch_csrf"
@ -947,19 +1012,19 @@ testConfig = Config
, cfgOidcIssuer = "https://issuer.example" , cfgOidcIssuer = "https://issuer.example"
, cfgOidcClientId = "nuthatch" , cfgOidcClientId = "nuthatch"
, cfgOidcAdditionalAudiences = [] , cfgOidcAdditionalAudiences = []
, cfgOidcClientSecret = "unit-test-client-secret" , cfgOidcClientSecret = secret "unit-test-client-secret"
, cfgOidcRedirectUri = "https://nuthatch.example/oidc/callback" , cfgOidcRedirectUri = "https://nuthatch.example/oidc/callback"
, cfgOidcTransactionMinutes = 5 , cfgOidcTransactionMinutes = 5
, cfgOidcMaxIdTokenSeconds = 3600 , cfgOidcMaxIdTokenSeconds = 3600
, cfgPgConn = "" , cfgPgConn = secret ""
, cfgRedisHost = "" , cfgRedisHost = ""
, cfgRedisPort = 6379 , cfgRedisPort = 6379
, cfgRedisDb = 0 , cfgRedisDb = 0
, cfgS3Endpoint = "https://s3.example" , cfgS3Endpoint = "https://s3.example"
, cfgS3Region = "test" , cfgS3Region = "test"
, cfgS3Bucket = "test" , cfgS3Bucket = "test"
, cfgS3AccessKey = "test" , cfgS3AccessKey = secret "test"
, cfgS3SecretKey = "test" , cfgS3SecretKey = secret "test"
, cfgS3PathStyle = True , cfgS3PathStyle = True
, cfgPersonalQuotaBytes = 1024 , cfgPersonalQuotaBytes = 1024
, cfgSharedQuotaBytes = 1024 , cfgSharedQuotaBytes = 1024
@ -974,6 +1039,9 @@ testConfig = Config
, cfgRecoveryGraceSec = 30 , cfgRecoveryGraceSec = 30
, cfgIdempotencyWindowSec = 3600 , cfgIdempotencyWindowSec = 3600
, cfgObjectRequestTimeoutSec = 300 , cfgObjectRequestTimeoutSec = 300
, cfgStartupAttempts = 3
, cfgStartupDelayMs = 10
, cfgAuditObjectKeys = False
} }
newtype ValidPath = ValidPath { getValidPath :: Text } newtype ValidPath = ValidPath { getValidPath :: Text }

View file

@ -362,6 +362,100 @@ spec:
exit 1 exit 1
fi fi
if [ "${NUTHATCH_RUNTIME_MODE:-}" = coherence ]; then
csrf_b="$(login "$NUTHATCH_OIDC_USER_B" b)"
test -n "$csrf_b"
for _ in 1 2 3 4; do
curl --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/api/browse?root=shared" >/tmp/shared-warm.json
curl --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/api/browse?root=shared&prefix=runtime" \
>/tmp/shared-prefix-warm.json
done
printf runtime-coherence >/tmp/runtime-body
runtime_size="$(wc -c </tmp/runtime-body | tr -d ' ')"
runtime_digest="$(
sha256sum /tmp/runtime-body | cut -d' ' -f1
)"
curl --fail --silent --show-error \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $runtime_size" \
--header "X-Content-SHA256: $runtime_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/runtime-body \
"$base/api/upload?root=shared&path=runtime%2Fcoherence.txt&operation_id=74000000-0000-4000-8000-000000000001" \
| grep -F '"result":"succeeded"'
curl --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/api/browse?root=shared" |
grep -F 'runtime'
curl --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/api/browse?root=shared&prefix=runtime" |
grep -F 'coherence.txt'
curl --fail --silent --show-error \
--cookie "$cookies" \
--header 'Content-Type: application/json' \
--data "{\"root\":\"shared\",\"path\":\"runtime/coherence.txt\",\"operation_id\":\"74000000-0000-4000-8000-000000000002\",\"csrf\":\"$csrf\"}" \
"$base/api/delete" |
grep -F '"result":"succeeded"'
if curl --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/api/browse?root=shared&prefix=runtime" |
grep -F 'coherence.txt'; then
exit 1
fi
sleep 2
curl --fail --silent --show-error \
--cookie /tmp/cookies-b \
--header 'Accept: application/json' \
"$base/api/browse?root=shared" >/dev/null
printf 'Cross-user cache coherence and TTL probes passed\n'
exit 0
fi
if [ "${NUTHATCH_RUNTIME_MODE:-}" = database-unavailable ]; then
printf 'Database fault checkpoint\n'
sleep 10
printf x >/tmp/database-body
database_digest="$(
sha256sum /tmp/database-body | cut -d' ' -f1
)"
test "$(
curl --silent --output /tmp/database-upload.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 1' \
--header "X-Content-SHA256: $database_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/database-body \
"$base/api/upload?root=personal&path=database-unavailable.txt&operation_id=75000000-0000-4000-8000-000000000001"
)" = 503
grep -F '"error":"mutation_rejected"' \
/tmp/database-upload.json
test "$(
curl --silent --output /tmp/database-delete.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/json' \
--data "{\"root\":\"personal\",\"path\":\"cross-user.txt\",\"operation_id\":\"75000000-0000-4000-8000-000000000002\",\"csrf\":\"$csrf\"}" \
"$base/api/delete"
)" = 503
grep -F '"error":"mutation_rejected"' \
/tmp/database-delete.json
printf 'Database-unavailable mutation probes passed\n'
exit 0
fi
if [ "${NUTHATCH_PRODUCT_MODE:-}" = timeout ]; then if [ "${NUTHATCH_PRODUCT_MODE:-}" = timeout ]; then
curl --insecure --fail --silent --show-error \ curl --insecure --fail --silent --show-error \
--get \ --get \
@ -1204,6 +1298,16 @@ spec:
fi fi
if [ "${NUTHATCH_STREAMING_MODE:-}" = unavailable ]; then if [ "${NUTHATCH_STREAMING_MODE:-}" = unavailable ]; then
curl --fail --silent --show-error \
--cookie "$cookies" \
--header 'Accept: application/json' \
"$base/api/browse?root=personal" |
grep -F 'cross-user.txt'
curl --fail --silent --show-error \
--cookie "$cookies" \
--header 'Accept: application/json' \
"$base/api/browse?root=personal&q=CROSS-USER" |
grep -F 'cross-user.txt'
printf x >/tmp/tiny printf x >/tmp/tiny
tiny_digest="$(sha256sum /tmp/tiny | cut -d' ' -f1)" tiny_digest="$(sha256sum /tmp/tiny | cut -d' ' -f1)"
test "$( test "$(
@ -1223,11 +1327,11 @@ spec:
curl --silent --output /tmp/unavailable-download.json \ curl --silent --output /tmp/unavailable-download.json \
--write-out '%{http_code}' \ --write-out '%{http_code}' \
--cookie "$cookies" \ --cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" "$base/download?root=personal&path=cross-user.txt"
)" = 503 )" = 503
grep -F '"error":"throttle_unavailable"' \ grep -F '"error":"throttle_unavailable"' \
/tmp/unavailable-download.json /tmp/unavailable-download.json
printf 'Unavailable coordination probes passed\n' printf 'Redis-unavailable product probes passed\n'
exit 0 exit 0
fi fi
@ -1370,7 +1474,7 @@ spec:
if curl --insecure --fail --silent \ if curl --insecure --fail --silent \
--cookie "$cookies" \ --cookie "$cookies" \
--header 'X-CSRF-Token: invalid' \ --header 'X-CSRF-Token: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \
--request POST \ --request POST \
"$base/logout" >/dev/null; then "$base/logout" >/dev/null; then
exit 1 exit 1
@ -1468,6 +1572,12 @@ spec:
name: @@PROFILE_NAME@@ name: @@PROFILE_NAME@@
key: NUTHATCH_PRODUCT_MODE key: NUTHATCH_PRODUCT_MODE
optional: true optional: true
- name: NUTHATCH_RUNTIME_MODE
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_RUNTIME_MODE
optional: true
- name: NUTHATCH_IDEMPOTENCY_IP_A - name: NUTHATCH_IDEMPOTENCY_IP_A
valueFrom: valueFrom:
configMapKeyRef: configMapKeyRef:

Some files were not shown because too many files have changed in this diff Show more