Skip to content

Commit 830af66

Browse files
committed
Allows permissions to run without reading the request body
This introduces `PermissionRequest`, a variant of `HandlerRequest` for which the body has not been read or parsed yet. This allows the permission actions to be run and reject the request without having to ever read the body. A permission action than needs the body to check permissions can read it explicitly using the handle provided to it. The body will be only by read once if it is used by both the permission action and the handler.
1 parent 37406c6 commit 830af66

5 files changed

Lines changed: 219 additions & 31 deletions

File tree

‎orb.cabal‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ test-suite orb-test
130130
Fixtures.NullableRef
131131
Fixtures.NullableRefCollectComponents
132132
Fixtures.OpenApiSubset
133+
Fixtures.PermissionReadsBody
133134
Fixtures.SchemaBounds
134135
Fixtures.SchemaDescriptions
135136
Fixtures.SimpleGet

‎src/Orb/Handler/Handler.hs‎

Lines changed: 64 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
module Orb.Handler.Handler
1313
( Handler (..)
14-
, HandlerRequest (..)
14+
, RequestWithBody (..)
15+
, HandlerRequest
16+
, PermissionRequest
1517
, runHandler
1618
, HasHandler (..)
1719
, NoRequestBody (..)
@@ -23,6 +25,8 @@ module Orb.Handler.Handler
2325
, formDataRequestBody
2426
, formDataRequestBodyWith
2527
, emptyRequestBody
28+
, BodyHandle
29+
, decodeBody
2630
, NoRequestQuery (..)
2731
, RequestQuery (..)
2832
, schemaRequestQuery
@@ -38,6 +42,7 @@ module Orb.Handler.Handler
3842
where
3943

4044
import Beeline.Params qualified as BP
45+
import Control.Concurrent.MVar qualified as MVar
4146
import Control.Exception.Safe qualified as Safe
4247
import Control.Monad.IO.Class qualified as MIO
4348
import Data.ByteString.Lazy qualified as LBS
@@ -58,21 +63,29 @@ import Orb.HasRequest qualified as HasRequest
5863
import Orb.HasRespond qualified as HasRespond
5964
import Orb.Response qualified as Response
6065

61-
data HandlerRequest route = HandlerRequest
66+
data RequestWithBody body route = HandlerRequest
6267
{ reqRoute :: route
63-
, reqBody :: HandlerRequestBody route
68+
, reqBody :: body
6469
, reqQuery :: HandlerRequestQuery route
6570
, reqHeaders :: HandlerRequestHeaders route
6671
}
6772

73+
type HandlerRequest route =
74+
RequestWithBody (HandlerRequestBody route) route
75+
76+
type PermissionRequest route =
77+
RequestWithBody
78+
(BodyHandle (S.TaggedUnion (HandlerResponses route)) (HandlerRequestBody route))
79+
route
80+
6881
data Handler route = Handler
6982
{ handlerId :: String
7083
, requestBody :: RequestBody (HandlerRequestBody route) (HandlerResponses route)
7184
, handlerResponseBodies :: Response.ResponseBodies (HandlerResponses route)
7285
, requestQuery :: RequestQuery (HandlerRequestQuery route) (HandlerResponses route)
7386
, requestHeaders :: RequestHeaders (HandlerRequestHeaders route) (HandlerResponses route)
7487
, mkPermissionAction ::
75-
HandlerRequest route ->
88+
PermissionRequest route ->
7689
HandlerPermissionAction route
7790
, handleRequest ::
7891
HandlerRequest route ->
@@ -180,6 +193,27 @@ emptyRequestBody =
180193
, requestBodyParser = const (pure (Right NoRequestBody))
181194
}
182195

196+
newtype BodyHandle err body
197+
= BodyHandle (IO (Either err body))
198+
199+
decodeBody :: MIO.MonadIO m => BodyHandle err body -> m (Either err body)
200+
decodeBody (BodyHandle decode) =
201+
MIO.liftIO decode
202+
203+
newBodyHandle :: IO (Either err body) -> IO (BodyHandle err body)
204+
newBodyHandle decode = do
205+
cacheVar <- MVar.newMVar Nothing
206+
pure . BodyHandle $ do
207+
exceptionOrResult <-
208+
MVar.modifyMVar cacheVar $ \cached ->
209+
case cached of
210+
Just result ->
211+
pure (cached, result)
212+
Nothing -> do
213+
result <- Safe.tryAny decode
214+
pure (Just result, result)
215+
either Safe.throwIO pure exceptionOrResult
216+
183217
data NoRequestQuery
184218
= NoRequestQuery
185219

@@ -278,20 +312,14 @@ runHandler handler route = do
278312
Left errResponse ->
279313
pure errResponse
280314
Right query -> do
281-
errOrBody <- readBody handler
282-
case errOrBody of
283-
Left errResponse -> pure errResponse
284-
Right body ->
285-
let
286-
request =
287-
HandlerRequest
288-
{ reqRoute = route
289-
, reqBody = body
290-
, reqQuery = query
291-
, reqHeaders = headers
292-
}
293-
in
294-
runPermissionAction handler request
315+
body <- newRequestBodyHandle handler
316+
runPermissionAction handler $
317+
HandlerRequest
318+
{ reqRoute = route
319+
, reqBody = body
320+
, reqQuery = query
321+
, reqHeaders = headers
322+
}
295323

296324
let
297325
responseData =
@@ -330,39 +358,44 @@ readQuery ::
330358
readQuery handler =
331359
requestQueryParser (requestQuery handler) <$> HasRequest.request
332360

333-
readBody ::
361+
newRequestBodyHandle ::
334362
( MIO.MonadIO m
335363
, HasRequest.HasRequest m
336364
, tags ~ HandlerResponses route
337365
) =>
338366
Handler route ->
339-
m (Either (S.TaggedUnion tags) (HandlerRequestBody route))
340-
readBody handler = do
367+
m (BodyHandle (S.TaggedUnion tags) (HandlerRequestBody route))
368+
newRequestBodyHandle handler = do
341369
req <- HasRequest.request
342-
MIO.liftIO $ requestBodyParser (requestBody handler) req
370+
MIO.liftIO . newBodyHandle $ requestBodyParser (requestBody handler) req
343371

344372
runPermissionAction ::
345-
( Monad m
373+
( MIO.MonadIO m
346374
, HasHandler route
347375
, PA.PermissionActionMonad (HandlerPermissionAction route) ~ m
348376
) =>
349377
Handler route ->
350-
HandlerRequest route ->
378+
PermissionRequest route ->
351379
m (S.TaggedUnion (HandlerResponses route))
352-
runPermissionAction handler request = do
380+
runPermissionAction handler permissionRequest = do
353381
let
354382
permissionAction =
355-
mkPermissionAction handler request
383+
mkPermissionAction handler permissionRequest
356384

357385
errOrPermissionResult <- PA.checkPermissionAction permissionAction
358386

359387
case errOrPermissionResult of
360388
Left err -> PE.returnPermissionError err
361-
Right permissionResult ->
362-
PA.runPermissionActionHandler
363-
permissionAction
364-
permissionResult
365-
(handleRequest handler request)
389+
Right permissionResult -> do
390+
errOrBody <- decodeBody (reqBody permissionRequest)
391+
case errOrBody of
392+
Left errResponse ->
393+
pure errResponse
394+
Right body ->
395+
PA.runPermissionActionHandler
396+
permissionAction
397+
permissionResult
398+
(handleRequest handler permissionRequest {reqBody = body})
366399

367400
returnAnyExceptionAs500 ::
368401
( MIO.MonadIO m

‎test/Fixtures.hs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Fixtures.NoPermissions as Export
1313
import Fixtures.NullableRef as Export
1414
import Fixtures.NullableRefCollectComponents as Export
1515
import Fixtures.OpenApiSubset as Export
16+
import Fixtures.PermissionReadsBody as Export
1617
import Fixtures.SchemaBounds as Export
1718
import Fixtures.SimpleGet as Export
1819
import Fixtures.SimplePost as Export
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE OverloadedStrings #-}
3+
{-# LANGUAGE TypeFamilies #-}
4+
{-# LANGUAGE UndecidableInstances #-}
5+
6+
module Fixtures.PermissionReadsBody
7+
( PermissionReadsBody (..)
8+
, permissionReadsBodyOpenApiRouter
9+
) where
10+
11+
import Beeline.Routing ((/-), (/:))
12+
import Beeline.Routing qualified as R
13+
import Data.Text qualified as T
14+
import Fleece.Core ((#+))
15+
import Fleece.Core qualified as FC
16+
import Network.HTTP.Types qualified as HTTP
17+
import Network.Wai qualified as Wai
18+
import Shrubbery qualified as S
19+
20+
import Orb qualified
21+
import TestDispatchM qualified as TDM
22+
23+
permissionReadsBodyOpenApiRouter :: Orb.OpenApiProvider r => r (S.Union '[PermissionReadsBody])
24+
permissionReadsBodyOpenApiRouter =
25+
Orb.provideOpenApi "permission-reads-body"
26+
. R.routeList
27+
$ Orb.post (R.make PermissionReadsBody /- "test" /- "permission_reads_body")
28+
/: R.emptyRoutes
29+
30+
data PermissionReadsBody = PermissionReadsBody
31+
32+
instance Orb.HasHandler PermissionReadsBody where
33+
type HandlerRequestBody PermissionReadsBody = PermissionReadsBodyBody
34+
type HandlerResponses PermissionReadsBody = Responses
35+
type HandlerPermissionAction PermissionReadsBody = BodyPermission
36+
type HandlerMonad PermissionReadsBody = TDM.TestDispatchM
37+
routeHandler = handler
38+
39+
type Responses =
40+
[ Orb.Response "200" Orb.SuccessMessage
41+
, Orb.Response "401" Orb.UnauthorizedMessage
42+
, Orb.Response "422" Orb.UnprocessableContentMessage
43+
, Orb.Response "500" Orb.InternalServerError
44+
]
45+
46+
newtype PermissionReadsBodyBody = PermissionReadsBodyBody
47+
{ permissionReadsBodyBodyParam :: T.Text
48+
}
49+
50+
permissionReadsBodyBodySchema :: FC.Fleece t => FC.Schema t PermissionReadsBodyBody
51+
permissionReadsBodyBodySchema =
52+
FC.object $
53+
FC.constructor PermissionReadsBodyBody
54+
#+ FC.required "postParam" permissionReadsBodyBodyParam FC.text
55+
56+
newtype BodyPermission
57+
= BodyPermission (Orb.BodyHandle (S.TaggedUnion Responses) PermissionReadsBodyBody)
58+
59+
newtype BodyPermissionError = BodyPermissionError T.Text
60+
61+
instance Orb.PermissionAction BodyPermission where
62+
type PermissionActionMonad BodyPermission = TDM.TestDispatchM
63+
type PermissionActionError BodyPermission = BodyPermissionError
64+
type PermissionActionResult BodyPermission = ()
65+
66+
checkPermissionAction (BodyPermission body) = do
67+
request <- Orb.request
68+
if lookup HTTP.hAuthorization (Wai.requestHeaders request) /= Just "Bearer good"
69+
then pure (Left (BodyPermissionError "Invalid token"))
70+
else do
71+
errOrBody <- Orb.decodeBody body
72+
pure $
73+
case errOrBody of
74+
Right decoded
75+
| permissionReadsBodyBodyParam decoded /= "allowed" ->
76+
Left (BodyPermissionError "Not allowed")
77+
_ ->
78+
Right ()
79+
80+
instance Orb.PermissionError BodyPermissionError where
81+
type PermissionErrorConstraints BodyPermissionError tags = Orb.Has401Response tags
82+
type PermissionErrorMonad BodyPermissionError = TDM.TestDispatchM
83+
84+
returnPermissionError (BodyPermissionError message) =
85+
Orb.returnResponse Orb.status401 (Orb.UnauthorizedMessage message)
86+
87+
handler :: Orb.Handler PermissionReadsBody
88+
handler =
89+
Orb.Handler
90+
{ Orb.handlerId = "permissionReadsBody"
91+
, Orb.requestBody = Orb.schemaRequestBody permissionReadsBodyBodySchema
92+
, Orb.requestQuery = Orb.emptyRequestQuery
93+
, Orb.requestHeaders = Orb.emptyRequestHeaders
94+
, Orb.handlerResponseBodies =
95+
Orb.responseBodies
96+
. Orb.addResponseSchema Orb.status200 Orb.successMessageSchema
97+
. Orb.addResponseSchema Orb.status401 Orb.unauthorizedMessageSchema
98+
. Orb.addResponseSchema Orb.status422 Orb.unprocessableContentSchema
99+
. Orb.addResponseSchema Orb.status500 Orb.internalServerErrorSchema
100+
$ Orb.noResponseBodies
101+
, Orb.mkPermissionAction =
102+
BodyPermission . Orb.reqBody
103+
, Orb.handleRequest =
104+
\request () ->
105+
Orb.returnResponse Orb.status200
106+
. Orb.SuccessMessage
107+
. permissionReadsBodyBodyParam
108+
. Orb.reqBody
109+
$ request
110+
}

‎test/Handler.hs‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ module Handler
88

99
import Beeline.Routing qualified as R
1010
import Control.Monad.IO.Class qualified as MIO
11+
import Data.ByteString qualified as BS
12+
import Data.ByteString.Lazy qualified as LBS
1113
import Hedgehog qualified as HH
1214
import Network.HTTP.Types qualified as HTTPTypes
1315
import Network.Wai qualified as Wai
@@ -34,6 +36,10 @@ testGroup =
3436
, TastyHH.testProperty "serves a custom status code" prop_customStatusCode
3537
, TastyHH.testProperty "serves a schema body with a custom parse error response" prop_customBodyError
3638
, TastyHH.testProperty "responds to an unparseable schema body with a custom error" prop_customBodyErrorInvalid
39+
, TastyHH.testProperty "checks permission before decoding a schema body" prop_permissionBeforeBody
40+
, TastyHH.testProperty "reuses a body the permission action decoded" prop_permissionReadsBody
41+
, TastyHH.testProperty "lets a permission action reject based on the body" prop_permissionRejectsBody
42+
, TastyHH.testProperty "answers a body decode failure after permission" prop_permissionReadsInvalidBody
3743
]
3844

3945
prop_simpleGet :: HH.Property
@@ -172,6 +178,43 @@ prop_customBodyErrorInvalid = HH.withTests 1 . HH.property $ do
172178
WaiTest.assertContentType "application/json" response
173179
WaiTest.assertBodyContains "\"errorCode\":\"invalid_body\"" response
174180

181+
prop_permissionBeforeBody :: HH.Property
182+
prop_permissionBeforeBody = HH.withTests 1 . HH.property $
183+
evalAppSession Fixtures.permissionReadsBodyOpenApiRouter $ do
184+
response <- WaiTest.srequest (permissionReadsBodyRequest "Bearer bad" "not json")
185+
WaiTest.assertStatus 401 response
186+
WaiTest.assertBody "{\"unauthorized\":\"Invalid token\"}" response
187+
188+
prop_permissionReadsBody :: HH.Property
189+
prop_permissionReadsBody = HH.withTests 1 . HH.property $
190+
evalAppSession Fixtures.permissionReadsBodyOpenApiRouter $ do
191+
response <- WaiTest.srequest (permissionReadsBodyRequest "Bearer good" "{\"postParam\": \"allowed\"}")
192+
WaiTest.assertStatus 200 response
193+
WaiTest.assertBody "{\"success\":\"allowed\"}" response
194+
195+
prop_permissionRejectsBody :: HH.Property
196+
prop_permissionRejectsBody = HH.withTests 1 . HH.property $
197+
evalAppSession Fixtures.permissionReadsBodyOpenApiRouter $ do
198+
response <- WaiTest.srequest (permissionReadsBodyRequest "Bearer good" "{\"postParam\": \"denied\"}")
199+
WaiTest.assertStatus 401 response
200+
WaiTest.assertBody "{\"unauthorized\":\"Not allowed\"}" response
201+
202+
prop_permissionReadsInvalidBody :: HH.Property
203+
prop_permissionReadsInvalidBody = HH.withTests 1 . HH.property $
204+
evalAppSession Fixtures.permissionReadsBodyOpenApiRouter $ do
205+
response <- WaiTest.srequest (permissionReadsBodyRequest "Bearer good" "not json")
206+
WaiTest.assertStatus 422 response
207+
WaiTest.assertContentType "application/json" response
208+
WaiTest.assertBodyContains "\"unprocessable_content\"" response
209+
210+
permissionReadsBodyRequest :: BS.ByteString -> LBS.ByteString -> WaiTest.SRequest
211+
permissionReadsBodyRequest token =
212+
WaiTest.SRequest
213+
(WaiTest.setPath Wai.defaultRequest "/test/permission_reads_body")
214+
{ Wai.requestMethod = HTTPTypes.methodPost
215+
, Wai.requestHeaders = [(HTTPTypes.hAuthorization, token)]
216+
}
217+
175218
evalAppSession ::
176219
( Orb.Dispatchable TDM.TestDispatchM a
177220
, HH.MonadTest m

0 commit comments

Comments
 (0)