
Installation and Use
This tutorial/demo that shows how to build web-apps with STORM. Like STORM, the demo focuses only on the web-server back-end. You can use your favorite front-end framework to write the client. For examples, look at voltron and disco, two complete applications we built with STORM and vue.js.
Here’s a video demonstrating disco.
Prerequisites
STORM has two dependencies: the Haskell build tool stack and the SMT solver z3.
stack v2.5.1 Install using these instructions
z3 v4.8.8 Install by placing a copy of the precompiled
z3binary in your$PATH. You can ignore the shared libraries and bindings for Java and Python.
Additionally this demo will use curl to test our app as we go.
Install
To start, you need to install storm-codegen
$ git clone git@github.com:storm-framework/storm-codegen.git
$ cd storm-codegen
$ stack installNext, clone the starter template app
$ git clone git@github.com:storm-framework/demo.git storm-app
$ cd storm-app
$ git checkout v0This tutorial is split into multiple pieces or versions which can be found in the the cloned repo as branches v0, v1, v2, v3, v4, and v5. You can skip ahead and look at the progression via
$ git diff v0..v1Architecture
A STORM app has the following essential components.
| File | Description |
|---|---|
Models.storm |
Model-policy file |
Routes.hs |
Route-controller mapping |
Types.hs |
Global type definitions |
Config.hs |
Configuration options (e.g. env variables) |
Controllers/ |
Controller implementations |
Build
You can build the web-app by typing
$ make buildThis will take a long while to download all the dependencies, including building the LiquidHaskell verifier. Grab some coffee, like some tweets, brush your teeth, or go for a jog. Fortunately, you only need to do this once; subsequent make should be quick.
Version 0: Kick the Tires
Lets kick the tires to make sure stuff is working.
Step 1. Fire up the server
$ makeYou should see the following at your terminal
Starting server at: host = "127.0.0.1", port = 3000Step 2. Test
In a separate terminal test your server with curl
$ curl http://localhost:3000/api/ping
"pong"Version 1: Routes and Controllers
(You can “cheat” by switching to the branch v1 in the repo.)
Next, lets add our first route and its controller.
Step 1: Add a Route
Edit src/Routes.hs so that the definition of routes looks like
routes :: [Route]
routes =
[ post "/api/signin" signIn
, post "/api/signout" signOut
, get "/api/ping" pong
, get "/api/list/:uid" list -- add this line
]Step 2: Implement a Controller
Edit src/Controllers/List.hs to add the following definition that simply responds with a string repeating the UserId and the current time.
{-@ list :: UserId -> TaggedT<{\_ -> False}, {\_ -> True}> _ _ _ @-}
list :: UserId -> Controller ()
list uid = do
time <- getTime
let msg = "OK: hello " <> tShow uid <> " at " <> time :: T.Text
respondJSON status200 msg
getTime :: Controller T.Text
getTime = tShow <$> liftTIO currentTimeThe text inside {-@ ... @-} is a refinement type signature that tells the verifier that list is a top-level controller. For more details see [the paper].
Step 3: Test
Run scripts/test_1.sh to test your new route and controller!
$ ./scripts/test_1.sh
list ... returns current time UTC
"OK: hello ... {unSqlBackendKey = 1} at 2021-07-13 22:53:25.51654 UTC"
list ... returns current time UTC
"OK: hello ... {unSqlBackendKey = 2} at 2021-07-13 22:53:25.5285 UTC"
list ... returns current time UTC
"OK: hello ... {unSqlBackendKey = 3} at 2021-07-13 22:53:25.537898 UTC"Version 2: Adding Authentication
Lets look at the model-policy file src/Models.storm
The User table defines the set of users of the app.
User
emailAddress Text
password ByteString
firstName Text
lastName Text
UniqueEmailAddress emailAddress
Step 1: Require Authentication
Lets modify the list controller (in src/Controllers/List.hs) to use requireAuthUser which succeeds only when the request is from a logged in user.
{-@ list :: UserId -> TaggedT<{\_ -> False}, {\_ -> True}> _ _ _ @-}
list :: UserId -> Controller ()
list uid = do
requireAuthUser
time <- getTime
user <- selectFirstOr notFoundJSON (userId' ==. uid)
userNG <- extractUserNG user
let msg = "OK: " <> tShow userNG <> " at " <> time
respondJSON status200 msg Additionally, notice that
We use the query
selectFirstOrwith the query(userId' ==. uid)to find the first record in theUsertable whose primary key equalsuid– i.e. to find the record foruidif it exists or else, respond withNot Found.We use
extractUserNG, which internally, usesprojectto pull out the values of individual field of theuserrecord to build the pure valueuserNGsent back to the client.
Step 2: Test
Run make to build and run the server in one terminal.
In another test your updated controller with scripts/test_2.sh.
add users
... INFO: addUser: CreateUser {crUserEmail = "alice@st.orm" ...}
... INFO: addUser: CreateUser {crUserEmail = "bob@st.orm" ...}
... INFO: addUser: CreateUser {crUserEmail = "carlos@st.orm" ...}
list ... returns nothing
sign-in first!
{"firstName":"Alice","lastName":"Waters"}
list ... now succeeds
"OK: UserNG {userFirstName = \"Alice\", userLastName = \"Waters\"} ..."
list ... now succeeds
"OK: UserNG {userFirstName = \"Robert\", userLastName = \"Barron\"} ..."
list ... returns {} as no such user
{}Note that we don’t get any response until we’re signed in.
Version 3: Adding Items
Next, lets start populating our users’ lists with some items!
Step 1: Add a Database Table
First, lets make an Item table by editing src/Models.storm to include
Item
owner UserId
description Text
level String
Similarly, we want a JSON-compatible pure representation of Item so edit src/Types.hs to
data ItemData = ItemData
{ itemDescription :: Text
, itemLevel :: String
}
deriving (Show, Generic)
mkItemData :: Text -> String -> ItemData
mkItemData descr levl = ItemData (strip descr) (sStrip levl)
instance FromJSON ItemData where
parseJSON = genericParseJSON (stripPrefix "item")
instance ToJSON ItemData where
toEncoding = genericToEncoding (stripPrefix "item")Step 2: Implement the API to Add Items
Next, lets make a new API endpoint to insert items for a user
Edit src/Routes.hs to include
routes :: [Route]
routes =
[ post "/api/signin" signIn
, post "/api/signout" signOut
, get "/api/ping" pong
, get "/api/list/:uid" list
, get "/api/add" add
]Edit src/Controllers/List.hs to include the controller
{-@ add :: TaggedT<{\_ -> True}, {\_ -> True}> _ _ _ @-}
add :: Controller ()
add = do
owner <- requireAuthUser
ownerId <- project userId' owner
ownerEm <- project userEmailAddress' owner
items <- decodeBody
items' <- mapT (\ItemData {..} ->
insert (mkItem ownerId itemDescription itemLevel)
) items
let msg = "OK: add " <> tShow (length items') <> " items " <> ownerEm
respondJSON status200 msgNote how the controller
- Finds the
ownerIdandownerEmailusingrequireAuthUserandproject - Extracts the JSON payload via
decodeBody - Uses
mkItemto create theItemrecords and - Uses
insertto add the records to the DB.
Step 3: Modify list Return Items
Next, lets update list to return the items of the given UserId (instead of just responding with their names…). Edit the src/Controllers/List.hs so that
list userId = do
items <- selectList (itemOwner' ==. userId)
itemDs <- mapT (\i ->
ItemData `fmap` project itemDescription' i
<*> project itemLevel' i
) items
respondJSON status200 itemDsNow, notice that first selectList is invoked with a query asking for all the Items whose owner is userId. Next, we build a response object by projecting the description and level values of each record in items to build an array of JSON objects that is sent back to the client.
Step 4: Test
The file scripts/alice_json has a few items for alice:
$ more scripts/items_alice.json
[ {"description":"katla","level":"public"},
{"description":"counterpart","level":"public"},
{"description":"marley and me","level":"private"},
{"description":"daniel tiger","level":"follower"},
{"description":"lupin","level":"follower"}
]Lets test the app by adding and querying the server for those items
$ ./scripts/test_3.sh
********* add users
...
******** sign-in alice!
{"firstName":"Alice","lastName":"Waters"}
******** add items for alice
"OK: add 5 items for alice@st.orm"
******* logout
******** sign-in alice!
{"firstName":"Alice","lastName":"Waters"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"},
{"description":"marley and me","level":"private"},
{"description":"daniel tiger","level":"follower"},
{"description":"lupin","level":"follower"}]
******** logout
******** sign-in bob!
{"firstName":"Robert","lastName":"Barron"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"},
{"description":"marley and me","level":"private"},
{"description":"daniel tiger","level":"follower"},
{"description":"lupin","level":"follower"}]
******** logoutVersion 4: Restrict to Public Items
Oops, we hit a bit of a snag – bob (or anyone for that matter!) can see all of alice’s items – even the ones marked "private"!
Step 1: Specify a Security Policy
Lets specify that only items marked "public" should be visible to users other than the owner, who should, of course, be able to see all their own items.
Edit src/Models.storm to include two predicates
IsOwner item viewerwhich is true whenviewerownsitem,IsPublic itemwhich is true when theitemis marked"public".
policy IsOwner = \item viewer ->
itemOwner item == entityKey viewer
policy IsPublic = \item ->
itemLevel item == "public"
policy OwnPublic = \item viewer ->
IsOwner item viewer || IsPublic item
and further add a read clause to restrict description the owner-unless-marked-public
Item
Item
owner UserId
description Text
level String
read [ description ] @OwnPublic
Now when you run make, compilation fails with a type error!
**** LIQUID: UNSAFE **************************************************
../storm-demo/src/Controllers/List.hs:43:40: error:
Liquid Type Mismatch
...
|
43 | ItemData `fmap` project itemDescription' i
| ^^^^^^^^^^^^^^^^STORM is unimpressed as selectList yields all of userId’s items but the viewer can be someone else who is not allowed (per OwnPublic) to view those descriptions!
Step 2. Fix the Controller
Lets fix the controller by checking if the viewer is userId and otherwise, by conjoining the query with a clause that the Item’s level be "public". Edit src/Controllers/List.hs so that list looks like
{-@ list :: UserId -> TaggedT<{\_ -> False}, {\_ -> True}> _ _ _ @-}
list :: UserId -> Controller ()
list userId = do
viewId <- project userId' =<< requireAuthUser
let pub = if userId == viewId
then trueF
else itemLevel' ==. "public"
items <- selectList (itemOwner' ==. userId &&: pub)
itemDs <- mapT (\i -> ItemData `fmap` project itemDescription' i
<*> project itemLevel' i
) items
respondJSON status200 itemDsStep 3. Test
STORM gladly verifies that the fix controller is policy compliant! Lets test our server to make sure that only public items are shown to users other than alice
$ scripts/test_3.sh
********* add users
...
******** sign-in alice!
{"firstName":"Alice","lastName":"Waters"}
******** add items for alice
"OK: add 5 items for alice@st.orm"
******* logout
******** sign-in alice!
{"firstName":"Alice","lastName":"Waters"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"},
{"description":"marley and me","level":"private"},
{"description":"daniel tiger","level":"follower"},
{"description":"lupin","level":"follower"}]
******** logout
******** sign-in bob!
{"firstName":"Robert","lastName":"Barron"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"}]
******** logoutNote that bob only gets to see the subset of public items.
Version 5 : Restrict to Followers
As a last step, lets add a feature where a user can share some of their items with followers.
Step 1. Add Followers to the Model
Add the below definitions to represent the followers relationship in src/Models.storm.
predicate follows :: UserId -> UserId -> Bool
Follower
subscriber UserId
publisher UserId
status String
assert {status == "accepted" => follows subscriber publisher}
The Follower table records the actual relationship. The predicate follows reflects the relationship at the logical “policy” level, using the assert clause which says that each tuple in the Follower table bears witness to the fact that its subscriber field follows its publisher field, when the status field has the value "accepted". (This lets one user initiate the relationship, which is only cemented when the other user accepts.)
Step 2. Modify Policy to Allow Followers Access
Next, lets update the policies for Item to allow the owner’s followers to read the description. Edit src/Models.storm to define
policy IsFollower = \item viewer ->
itemLevel item == "follower" &&
follows (entityKey viewer) (itemOwner item)
policy OwnFollowPub = \item viewer ->
IsOwner item viewer ||
IsFollower item viewer ||
IsPublic item
and then edit the read clause with the new OwnFollowPub policy
Item
owner UserId
description Text
level String
read [ description ] @OwnFollowPub
Step 3. Update the Controller
The old controller should still compile – but its not what we want as it still only shows the public items to other users.
$ script/test_5.sh
********* add users
...
******** bob follows alice
******** sign-in bob!
{"firstName":"Robert","lastName":"Barron"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"}]
******** logoutOf course, we have to change how the controller behaves as well, by making it issue queries that also return "follower" level items. Edit src/Controllers/List.hs to include a function checkFollower vId uId that queries the database to determine whether vId is a follower of uId
{-@ checkFollower ::
vId:_ -> uId:_ ->
TaggedT<{\_ -> True}, {\_ -> False}> _ _ {b:_|b => follows vId uId}
@-}
checkFollower :: UserId -> UserId -> Controller Bool
checkFollower vId uId = do
flws <- selectList (followerSubscriber' ==. vId &&:
followerPublisher' ==. uId)
case flws of
[] -> return False
_ -> return TrueThe text inside {-@ ... @-} says that checkFollower is a read-only controller, that accesses data visible to all users (True) and sends data to no users (False). Furthermore, the output is a Bool which if True implies the follows relationship holds between the input UserIds. For more details on the signatures, see the paper.
Next, update list in src/Controllers/List.hs to use a separate conjunct if the viewer is indeed a follower of userId
{-@ list :: _ -> TaggedT<{\_ -> False}, {\_ -> True}> _ _ _ @-}
list :: UserId -> Controller ()
list userId = do
viewerId <- project userId' =<< requireAuthUser
follower <- checkFollower viewerId userId
let self = viewerId == userId
let chk
| self = trueF
| follower = itemLevel' <-. ["public", "follower"]
| otherwise = itemLevel' ==. "public"
items <- selectList (itemOwner' ==. userId &&: chk)
itemDatas <- mapT (\i ->
ItemData `fmap` project itemDescription' i
<*> project itemLevel' i
)items
respondJSON status200 itemDatasOops, there is a type error, can you figure it out?
Hint: Is the query that checks the follower status correct?
Step 4. Test
Now, after fixing the type error, when you run the tests, we’ll see all the right items for users that are indeed followers!
********* add users
...
******** sign-in alice!
...
******** add items for alice
...
******* logout
******** sign-in alice!
{"firstName":"Alice","lastName":"Waters"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"},
{"description":"marley and me","level":"private"},
{"description":"daniel tiger","level":"follower"},
{"description":"lupin","level":"follower"}]
******** logout
******** sign-in bob!
{"firstName":"Robert","lastName":"Barron"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"}]
******** logout
******** bob follows alice
******** sign-in bob!
{"firstName":"Robert","lastName":"Barron"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"},
{"description":"daniel tiger","level":"follower"},
{"description":"lupin","level":"follower"}]
******** logout
******** sign-in carlos!
{"firstName":"Carlos","lastName":"Cortado"}
******** list items for alice ...
[{"description":"katla","level":"public"},
{"description":"counterpart","level":"public"}]
******** logoutNow alice sees all her items, after adding bob as a follower, he sees the public and shared items, but carlos only sees the public ones.
While the above should get you started, there are lots of important technical details that we’ve skipped over. Take a look at the OSDI 21 paper for more details or email the team if you want to contribute to or use STORM!