Merge pull request #320 from diogob/jwt_generic_variables
Jwt generic variables
This commit is contained in:
+40
-26
@@ -66,34 +66,48 @@ library
|
|||||||
|
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
||||||
build-depends: base >=4.6 && <5
|
build-depends: HTTP
|
||||||
, hasql, hasql-backend
|
, MissingH
|
||||||
, hasql-postgres
|
|
||||||
, warp, wai
|
|
||||||
, wai-extra, wai-cors
|
|
||||||
, wai-middleware-static
|
|
||||||
, HTTP, convertible, http-types
|
|
||||||
, case-insensitive
|
|
||||||
, scientific, time
|
|
||||||
, aeson, network
|
|
||||||
, bytestring, text, split, string-conversions
|
|
||||||
, stringsearch
|
|
||||||
, containers, unordered-containers
|
|
||||||
, optparse-applicative
|
|
||||||
, regex-base, regex-tdfa
|
|
||||||
, Ranged-sets
|
, Ranged-sets
|
||||||
, transformers, MissingH
|
, aeson
|
||||||
, bcrypt, base64-string
|
, base >=4.6 && <5
|
||||||
, network-uri
|
, base64-string
|
||||||
, resource-pool
|
, bcrypt
|
||||||
, blaze-builder
|
|
||||||
, vector
|
|
||||||
, mtl
|
|
||||||
, cassava
|
|
||||||
, jwt
|
|
||||||
, parsec
|
|
||||||
, errors
|
|
||||||
, bifunctors
|
, bifunctors
|
||||||
|
, blaze-builder
|
||||||
|
, bytestring
|
||||||
|
, case-insensitive
|
||||||
|
, cassava
|
||||||
|
, containers
|
||||||
|
, convertible
|
||||||
|
, errors
|
||||||
|
, hasql
|
||||||
|
, hasql-backend
|
||||||
|
, hasql-postgres
|
||||||
|
, http-types
|
||||||
|
, jwt
|
||||||
|
, mtl
|
||||||
|
, network
|
||||||
|
, network-uri
|
||||||
|
, optparse-applicative
|
||||||
|
, parsec
|
||||||
|
, regex-base
|
||||||
|
, regex-tdfa
|
||||||
|
, resource-pool
|
||||||
|
, scientific
|
||||||
|
, split
|
||||||
|
, string-conversions
|
||||||
|
, stringsearch
|
||||||
|
, text
|
||||||
|
, time
|
||||||
|
, transformers
|
||||||
|
, unordered-containers
|
||||||
|
, vector
|
||||||
|
, wai
|
||||||
|
, wai-cors
|
||||||
|
, wai-extra
|
||||||
|
, wai-middleware-static
|
||||||
|
, warp
|
||||||
|
|
||||||
Other-Modules: Paths_postgrest
|
Other-Modules: Paths_postgrest
|
||||||
Exposed-Modules: PostgREST.App
|
Exposed-Modules: PostgREST.App
|
||||||
|
|||||||
+12
-41
@@ -40,13 +40,13 @@ import Network.Wai.Internal (Response (..))
|
|||||||
import Network.Wai.Parse (parseHttpAccept)
|
import Network.Wai.Parse (parseHttpAccept)
|
||||||
|
|
||||||
import Data.Aeson
|
import Data.Aeson
|
||||||
|
import Data.Aeson.Types (emptyArray)
|
||||||
import Data.Monoid
|
import Data.Monoid
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Vector as V
|
||||||
import qualified Hasql as H
|
import qualified Hasql as H
|
||||||
import qualified Hasql.Backend as B
|
import qualified Hasql.Backend as B
|
||||||
import qualified Hasql.Postgres as P
|
import qualified Hasql.Postgres as P
|
||||||
|
|
||||||
import PostgREST.Auth
|
|
||||||
import PostgREST.Config (AppConfig (..))
|
import PostgREST.Config (AppConfig (..))
|
||||||
import PostgREST.Parsers
|
import PostgREST.Parsers
|
||||||
import PostgREST.PgQuery
|
import PostgREST.PgQuery
|
||||||
@@ -54,14 +54,16 @@ import PostgREST.PgStructure
|
|||||||
import PostgREST.QueryBuilder
|
import PostgREST.QueryBuilder
|
||||||
import PostgREST.RangeQuery
|
import PostgREST.RangeQuery
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
|
import PostgREST.Auth (tokenJWT)
|
||||||
|
|
||||||
import Prelude
|
import Prelude
|
||||||
|
|
||||||
app :: DbStructure -> AppConfig -> DbRole -> BL.ByteString -> DbRole -> Request -> H.Tx P.Postgres s Response
|
app :: DbStructure -> AppConfig -> BL.ByteString -> Request -> H.Tx P.Postgres s Response
|
||||||
app dbstructure conf authenticator reqBody dbrole req =
|
app dbstructure conf reqBody req =
|
||||||
case (path, verb) of
|
case (path, verb) of
|
||||||
|
|
||||||
([], _) -> do
|
([], _) -> do
|
||||||
|
Identity (dbrole :: Text) <- H.singleEx $ [H.stmt|SELECT current_user|]
|
||||||
let body = encode $ filter (filterTableAcl dbrole) $ filter ((cs schema==).tableSchema) allTabs
|
let body = encode $ filter (filterTableAcl dbrole) $ filter ((cs schema==).tableSchema) allTabs
|
||||||
return $ responseLBS status200 [jsonH] $ cs body
|
return $ responseLBS status200 [jsonH] $ cs body
|
||||||
|
|
||||||
@@ -130,41 +132,6 @@ app dbstructure conf authenticator reqBody dbrole req =
|
|||||||
countQuery = requestToCountQuery schema <$> apiRequest
|
countQuery = requestToCountQuery schema <$> apiRequest
|
||||||
queries = (,) <$> query <*> countQuery
|
queries = (,) <$> query <*> countQuery
|
||||||
|
|
||||||
|
|
||||||
(["postgrest", "users"], "POST") -> do
|
|
||||||
let user = decode reqBody :: Maybe AuthUser
|
|
||||||
|
|
||||||
case user of
|
|
||||||
Nothing -> return $ responseLBS status400 [jsonH] $
|
|
||||||
encode . object $ [("message", String "Failed to parse user.")]
|
|
||||||
Just u -> do
|
|
||||||
_ <- addUser (cs $ userId u)
|
|
||||||
(cs $ userPass u) (cs <$> userRole u)
|
|
||||||
return $ responseLBS status201
|
|
||||||
[ jsonH
|
|
||||||
, (hLocation, "/postgrest/users?id=eq." <> cs (userId u))
|
|
||||||
] ""
|
|
||||||
|
|
||||||
(["postgrest", "tokens"], "POST") ->
|
|
||||||
case jwtSecret of
|
|
||||||
"secret" -> return $ responseLBS status500 [jsonH] $
|
|
||||||
encode . object $ [("message", String "JWT Secret is set as \"secret\" which is an unsafe default.")]
|
|
||||||
_ -> do
|
|
||||||
let user = decode reqBody :: Maybe AuthUser
|
|
||||||
|
|
||||||
case user of
|
|
||||||
Nothing -> return $ responseLBS status400 [jsonH] $
|
|
||||||
encode . object $ [("message", String "Failed to parse user.")]
|
|
||||||
Just u -> do
|
|
||||||
setRole authenticator
|
|
||||||
login <- signInRole (cs $ userId u) (cs $ userPass u)
|
|
||||||
case login of
|
|
||||||
LoginSuccess role uid ->
|
|
||||||
return $ responseLBS status201 [ jsonH ] $
|
|
||||||
encode . object $ [("token", String $ tokenJWT jwtSecret uid role)]
|
|
||||||
_ -> return $ responseLBS status401 [jsonH] $
|
|
||||||
encode . object $ [("message", String "Failed authentication.")]
|
|
||||||
|
|
||||||
([table], "POST") -> do
|
([table], "POST") -> do
|
||||||
let qt = qualify table
|
let qt = qualify table
|
||||||
echoRequested = hasPrefer "return=representation"
|
echoRequested = hasPrefer "return=representation"
|
||||||
@@ -207,9 +174,13 @@ app dbstructure conf authenticator reqBody dbrole req =
|
|||||||
then do
|
then do
|
||||||
let call = B.Stmt "select " V.empty True <>
|
let call = B.Stmt "select " V.empty True <>
|
||||||
asJson (callProc qi $ fromMaybe M.empty (decode reqBody))
|
asJson (callProc qi $ fromMaybe M.empty (decode reqBody))
|
||||||
body :: Maybe (Identity Text) <- H.maybeEx call
|
bodyJson :: Maybe (Identity Value) <- H.maybeEx call
|
||||||
|
returnJWT <- doesProcReturnJWT schema proc
|
||||||
return $ responseLBS status200 [jsonH]
|
return $ responseLBS status200 [jsonH]
|
||||||
(cs $ fromMaybe "[]" $ runIdentity <$> body)
|
(let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in
|
||||||
|
if returnJWT
|
||||||
|
then "{\"token\":\"" <> cs (tokenJWT jwtSecret body) <> "\"}"
|
||||||
|
else cs $ encode body)
|
||||||
else return $ responseLBS status404 [] ""
|
else return $ responseLBS status404 [] ""
|
||||||
|
|
||||||
-- check that proc exists
|
-- check that proc exists
|
||||||
@@ -295,7 +266,7 @@ app dbstructure conf authenticator reqBody dbrole req =
|
|||||||
hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs
|
hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs
|
||||||
accept = lookupHeader hAccept
|
accept = lookupHeader hAccept
|
||||||
schema = cs $ configSchema conf
|
schema = cs $ configSchema conf
|
||||||
jwtSecret = cs $ configJwtSecret conf
|
jwtSecret = (cs $ configJwtSecret conf) :: Text
|
||||||
range = rangeRequested hdrs
|
range = rangeRequested hdrs
|
||||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||||
contentType = fromMaybe "application/json" $ contentTypeForAccept accept
|
contentType = fromMaybe "application/json" $ contentTypeForAccept accept
|
||||||
|
|||||||
+69
-97
@@ -1,104 +1,76 @@
|
|||||||
module PostgREST.Auth where
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
{-|
|
||||||
|
Module : PostgREST.Auth
|
||||||
|
Description : PostgREST authorization functions.
|
||||||
|
|
||||||
import Control.Applicative
|
This module provides functions to deal with the JWT authorization (http://jwt.io).
|
||||||
import Control.Monad (mzero)
|
It also can be used to define other authorization functions,
|
||||||
import Crypto.BCrypt
|
in the future Oauth, LDAP and similar integrations can be coded here.
|
||||||
import Data.Aeson
|
|
||||||
import Data.Map
|
Authentication should always be implemented in an external service.
|
||||||
import Data.Monoid
|
In the test suite there is an example of simple login function that can be used for a
|
||||||
|
very simple authentication system inside the PostgreSQL database.
|
||||||
|
-}
|
||||||
|
module PostgREST.Auth (
|
||||||
|
setRole
|
||||||
|
, claimsToSQL
|
||||||
|
, jwtClaims
|
||||||
|
, tokenJWT
|
||||||
|
) where
|
||||||
|
|
||||||
|
--line needed for ghc 7.8
|
||||||
|
import Data.Functor ((<$>))
|
||||||
|
|
||||||
|
import Data.Aeson (Value (..), Object)
|
||||||
|
import Data.Aeson.Types (emptyObject, emptyArray)
|
||||||
|
import Data.Vector as V (null, head)
|
||||||
|
import Data.Map as M (fromList, toList)
|
||||||
|
import Data.Monoid ((<>))
|
||||||
import Data.String.Conversions (cs)
|
import Data.String.Conversions (cs)
|
||||||
import Data.Text
|
import Data.Text (Text)
|
||||||
import Data.Maybe (isNothing)
|
import PostgREST.PgQuery (pgFmtLit, pgFmtIdent, unquoted)
|
||||||
import qualified Data.Vector as V
|
|
||||||
import qualified Hasql as H
|
|
||||||
import qualified Hasql.Backend as B
|
|
||||||
import qualified Hasql.Postgres as P
|
|
||||||
import PostgREST.PgQuery (pgFmtLit)
|
|
||||||
import Prelude
|
|
||||||
import qualified Web.JWT as JWT
|
import qualified Web.JWT as JWT
|
||||||
|
import qualified Data.HashMap.Lazy as H
|
||||||
|
|
||||||
import System.IO.Unsafe
|
{-|
|
||||||
|
Receives a map of JWT claims and returns a list
|
||||||
data AuthUser = AuthUser {
|
of PostgreSQL statements to set the claims as user defined GUCs.
|
||||||
userId :: String
|
Except if we have a claim called role,
|
||||||
, userPass :: String
|
this one is mapped to a SET ROLE statement.
|
||||||
, userRole :: Maybe String
|
In case there is any problem decoding the JWT it returns Nothing.
|
||||||
} deriving (Show)
|
-}
|
||||||
|
claimsToSQL :: JWT.ClaimsMap -> [Text]
|
||||||
instance FromJSON AuthUser where
|
claimsToSQL = map setVar . toList
|
||||||
parseJSON (Object v) = AuthUser <$>
|
where
|
||||||
v .: "id" <*>
|
setVar ("role", String val) = setRole val
|
||||||
v .: "pass" <*>
|
setVar (k, val) = "set local postgrest.claims." <> pgFmtIdent k <>
|
||||||
v .:? "role"
|
" = " <> valueToVariable val <> ";"
|
||||||
parseJSON _ = mzero
|
valueToVariable = pgFmtLit . unquoted
|
||||||
|
|
||||||
instance ToJSON AuthUser where
|
{-|
|
||||||
toJSON u = object [
|
Receives the JWT secret (from config) and a JWT and
|
||||||
"id" .= userId u
|
returns a map of JWT claims
|
||||||
, "pass" .= userPass u
|
In case there is any problem decoding the JWT it returns Nothing.
|
||||||
, "role" .= userRole u ]
|
-}
|
||||||
|
jwtClaims :: Text -> Text -> Maybe JWT.ClaimsMap
|
||||||
type DbRole = Text
|
jwtClaims secret input = JWT.unregisteredClaims <$> JWT.claims <$> decoded
|
||||||
type UserId = Text
|
|
||||||
|
|
||||||
data LoginAttempt =
|
|
||||||
NoCredentials
|
|
||||||
| MalformedAuth
|
|
||||||
| LoginFailed
|
|
||||||
| LoginSuccess DbRole UserId
|
|
||||||
deriving (Eq, Show)
|
|
||||||
|
|
||||||
checkPass :: Text -> Text -> Bool
|
|
||||||
checkPass = (. cs) . validatePassword . cs
|
|
||||||
|
|
||||||
setRole :: Text -> H.Tx P.Postgres s ()
|
|
||||||
setRole role = H.unitEx $ B.Stmt ("set local role " <> cs (pgFmtLit role)) V.empty True
|
|
||||||
|
|
||||||
setUserId :: Text -> H.Tx P.Postgres s ()
|
|
||||||
setUserId uid =
|
|
||||||
if uid /= ""
|
|
||||||
then H.unitEx $ B.Stmt ("set local user_vars.user_id = " <> cs (pgFmtLit uid)) V.empty True
|
|
||||||
else resetUserId
|
|
||||||
|
|
||||||
resetUserId :: H.Tx P.Postgres s ()
|
|
||||||
resetUserId = H.unitEx [H.stmt|reset user_vars.user_id|]
|
|
||||||
|
|
||||||
addUser :: Text -> Text -> Maybe Text -> H.Tx P.Postgres s ()
|
|
||||||
addUser identity pass role =
|
|
||||||
H.unitEx $
|
|
||||||
if isNothing role
|
|
||||||
then [H.stmt|insert into postgrest.auth (id, pass) values (?, ?)|]
|
|
||||||
identity hashedText
|
|
||||||
else [H.stmt|insert into postgrest.auth (id, pass, rolname) values (?, ?, ?)|]
|
|
||||||
identity hashedText role
|
|
||||||
where Just hashed = unsafePerformIO $ hashPasswordUsingPolicy fastBcryptHashingPolicy (cs pass)
|
|
||||||
hashedText = cs hashed :: Text
|
|
||||||
|
|
||||||
signInRole :: Text -> Text -> H.Tx P.Postgres s LoginAttempt
|
|
||||||
signInRole user pass = do
|
|
||||||
u <- H.maybeEx $ [H.stmt|select id, pass, rolname from postgrest.auth where id = ?|] user
|
|
||||||
return $ maybe LoginFailed (\r ->
|
|
||||||
let (uid, hashed, role) = r in
|
|
||||||
if checkPass hashed pass
|
|
||||||
then LoginSuccess role uid
|
|
||||||
else LoginFailed
|
|
||||||
) u
|
|
||||||
|
|
||||||
signInWithJWT :: Text -> Text -> LoginAttempt
|
|
||||||
signInWithJWT secret input = case maybeRole of
|
|
||||||
Just (Just (String role)) -> case maybeUserId of
|
|
||||||
Just (Just (String uid)) -> LoginSuccess (cs role) (cs uid)
|
|
||||||
_ -> LoginFailed
|
|
||||||
_ -> LoginFailed
|
|
||||||
where
|
where
|
||||||
maybeRole = (Data.Map.lookup "role" <$> claims) ::Maybe (Maybe Value)
|
|
||||||
maybeUserId = (Data.Map.lookup "id" <$> claims) ::Maybe (Maybe Value)
|
|
||||||
claims = JWT.unregisteredClaims <$> JWT.claims <$> decoded
|
|
||||||
decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input
|
decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input
|
||||||
|
|
||||||
tokenJWT :: Text -> Text -> Text -> Text
|
-- | Receives the name of a role and returns a SET ROLE statement
|
||||||
tokenJWT secret uid role = JWT.encodeSigned JWT.HS256 (JWT.secret secret) claimsSet
|
setRole :: Text -> Text
|
||||||
where
|
setRole role = "set local role " <> cs (pgFmtLit role) <> ";"
|
||||||
claimsSet = JWT.def {
|
|
||||||
JWT.unregisteredClaims = Data.Map.fromList [("id", String uid), ("role", String role)]
|
|
||||||
}
|
{-|
|
||||||
|
Receives the JWT secret (from config) and a JWT and a JSON value
|
||||||
|
and returns a signed JWT.
|
||||||
|
-}
|
||||||
|
tokenJWT :: Text -> Value -> Text
|
||||||
|
tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 (JWT.secret secret)
|
||||||
|
JWT.def { JWT.unregisteredClaims = fromHashMap o }
|
||||||
|
where
|
||||||
|
Object o = if V.null a then emptyObject else V.head a
|
||||||
|
fromHashMap :: Object -> JWT.ClaimsMap
|
||||||
|
fromHashMap = M.fromList . H.toList
|
||||||
|
tokenJWT secret _ = tokenJWT secret emptyArray
|
||||||
|
|||||||
@@ -98,5 +98,5 @@ main = do
|
|||||||
runSettings appSettings $ middle $ \ req respond -> do
|
runSettings appSettings $ middle $ \ req respond -> do
|
||||||
body <- strictRequestBody req
|
body <- strictRequestBody req
|
||||||
resOrError <- liftIO $ H.session pool $ H.tx txSettings $
|
resOrError <- liftIO $ H.session pool $ H.tx txSettings $
|
||||||
authenticated conf authenticator (app dbstructure conf authenticator body) req
|
runWithClaims conf (app dbstructure conf body) req
|
||||||
either (respond . errResponse) respond resOrError
|
either (respond . errResponse) respond resOrError
|
||||||
|
|||||||
+28
-39
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
module PostgREST.Middleware where
|
module PostgREST.Middleware where
|
||||||
|
|
||||||
|
-- needed for ghc 7.8
|
||||||
|
import Data.Functor ((<$>))
|
||||||
|
|
||||||
import Data.Maybe (fromMaybe, isNothing)
|
import Data.Maybe (fromMaybe, isNothing)
|
||||||
import Data.Monoid
|
import Data.Monoid
|
||||||
import Data.Text
|
import Data.Text
|
||||||
@@ -10,11 +13,9 @@ import Data.String.Conversions (cs)
|
|||||||
import qualified Hasql as H
|
import qualified Hasql as H
|
||||||
import qualified Hasql.Postgres as P
|
import qualified Hasql.Postgres as P
|
||||||
|
|
||||||
import Network.HTTP.Types (RequestHeaders)
|
|
||||||
import Network.HTTP.Types.Header (hAccept, hAuthorization,
|
import Network.HTTP.Types.Header (hAccept, hAuthorization,
|
||||||
hLocation)
|
hLocation)
|
||||||
import Network.HTTP.Types.Status (status301, status400, status401,
|
import Network.HTTP.Types.Status (status301, status400, status415)
|
||||||
status415)
|
|
||||||
import Network.URI (URI (..), parseURI)
|
import Network.URI (URI (..), parseURI)
|
||||||
import Network.Wai (Application, Request (..),
|
import Network.Wai (Application, Request (..),
|
||||||
Response, isSecure, rawPathInfo,
|
Response, isSecure, rawPathInfo,
|
||||||
@@ -24,49 +25,37 @@ import Network.Wai.Middleware.Cors (cors)
|
|||||||
import Network.Wai.Middleware.Gzip (def, gzip)
|
import Network.Wai.Middleware.Gzip (def, gzip)
|
||||||
import Network.Wai.Middleware.Static (only, staticPolicy)
|
import Network.Wai.Middleware.Static (only, staticPolicy)
|
||||||
|
|
||||||
import Codec.Binary.Base64.String (decode)
|
|
||||||
import PostgREST.App (contentTypeForAccept)
|
import PostgREST.App (contentTypeForAccept)
|
||||||
import PostgREST.Auth (DbRole, LoginAttempt (..),
|
import PostgREST.Auth (setRole, jwtClaims, claimsToSQL)
|
||||||
setRole, setUserId, signInRole,
|
|
||||||
signInWithJWT)
|
|
||||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||||
|
|
||||||
import Prelude
|
import Prelude hiding(concat)
|
||||||
|
|
||||||
authenticated :: forall s. AppConfig -> DbRole ->
|
import qualified Data.Vector as V
|
||||||
(DbRole -> Request -> H.Tx P.Postgres s Response) ->
|
import qualified Hasql.Backend as B
|
||||||
|
import qualified Data.Map.Lazy as M
|
||||||
|
|
||||||
|
runWithClaims :: forall s. AppConfig ->
|
||||||
|
(Request -> H.Tx P.Postgres s Response) ->
|
||||||
Request -> H.Tx P.Postgres s Response
|
Request -> H.Tx P.Postgres s Response
|
||||||
authenticated conf authenticator app req = do
|
runWithClaims conf app req = do
|
||||||
attempt <- httpRequesterRole (requestHeaders req)
|
mapM_ H.unitEx $ stmt <$> env
|
||||||
case attempt of
|
app req
|
||||||
MalformedAuth ->
|
|
||||||
return $ responseLBS status400 [] "Malformed basic auth header"
|
|
||||||
LoginFailed ->
|
|
||||||
return $ responseLBS status401 [] "Invalid username or password"
|
|
||||||
LoginSuccess role uid -> if role /= authenticator then runInRole role uid else app authenticator req
|
|
||||||
NoCredentials -> if anon /= authenticator then runInRole anon "" else app authenticator req
|
|
||||||
|
|
||||||
where
|
where
|
||||||
jwtSecret = cs $ configJwtSecret conf
|
stmt = (flip $ flip B.Stmt V.empty) True
|
||||||
|
hdrs = requestHeaders req
|
||||||
|
jwtSecret = (cs $ configJwtSecret conf) :: Text
|
||||||
|
auth = fromMaybe "" $ lookup hAuthorization hdrs
|
||||||
anon = cs $ configAnonRole conf
|
anon = cs $ configAnonRole conf
|
||||||
httpRequesterRole :: RequestHeaders -> H.Tx P.Postgres s LoginAttempt
|
claims =
|
||||||
httpRequesterRole hdrs = do
|
fromMaybe (M.fromList []) $
|
||||||
let auth = fromMaybe "" $ lookup hAuthorization hdrs
|
case split (==' ') (cs auth) of
|
||||||
case split (==' ') (cs auth) of
|
("Bearer" : jwt : _) -> jwtClaims jwtSecret jwt
|
||||||
("Basic" : b64 : _) ->
|
_ -> Nothing
|
||||||
case split (==':') (cs . decode . cs $ b64) of
|
env = if M.member "role" claims
|
||||||
(u:p:_) -> signInRole u p
|
then jwtEnv
|
||||||
_ -> return MalformedAuth
|
else setRole anon : jwtEnv
|
||||||
("Bearer" : jwt : _) ->
|
jwtEnv = claimsToSQL claims
|
||||||
return $ signInWithJWT jwtSecret jwt
|
|
||||||
_ -> return NoCredentials
|
|
||||||
|
|
||||||
runInRole :: Text -> Text -> H.Tx P.Postgres s Response
|
|
||||||
runInRole r uid = do
|
|
||||||
setUserId uid
|
|
||||||
setRole r
|
|
||||||
app r req
|
|
||||||
|
|
||||||
|
|
||||||
redirectInsecure :: Application -> Application
|
redirectInsecure :: Application -> Application
|
||||||
redirectInsecure app req respond = do
|
redirectInsecure app req respond = do
|
||||||
|
|||||||
@@ -13,25 +13,39 @@ import Data.Monoid
|
|||||||
import Data.Text (Text, split)
|
import Data.Text (Text, split)
|
||||||
import qualified Hasql as H
|
import qualified Hasql as H
|
||||||
import qualified Hasql.Postgres as P
|
import qualified Hasql.Postgres as P
|
||||||
|
import qualified Hasql.Backend as B
|
||||||
import PostgREST.PgQuery ()
|
import PostgREST.PgQuery ()
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
|
|
||||||
import GHC.Exts (groupWith)
|
import GHC.Exts (groupWith)
|
||||||
import Prelude
|
import Prelude
|
||||||
|
|
||||||
|
doesProc :: forall c s. B.CxValue c Int =>
|
||||||
|
(Text -> Text -> B.Stmt c) -> Text -> Text -> H.Tx c s Bool
|
||||||
|
doesProc stmt schema proc = do
|
||||||
|
row :: Maybe (Identity Int) <- H.maybeEx $ stmt schema proc
|
||||||
|
return $ isJust row
|
||||||
|
|
||||||
doesProcExist :: Text -> Text -> H.Tx P.Postgres s Bool
|
doesProcExist :: Text -> Text -> H.Tx P.Postgres s Bool
|
||||||
doesProcExist schema proc = do
|
doesProcExist = doesProc [H.stmt|
|
||||||
row :: Maybe (Identity Int) <- H.maybeEx $ [H.stmt|
|
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM pg_catalog.pg_namespace n
|
FROM pg_catalog.pg_namespace n
|
||||||
JOIN pg_catalog.pg_proc p
|
JOIN pg_catalog.pg_proc p
|
||||||
ON pronamespace = n.oid
|
ON pronamespace = n.oid
|
||||||
WHERE nspname = ?
|
WHERE nspname = ?
|
||||||
AND proname = ?
|
AND proname = ?
|
||||||
|] schema proc
|
|]
|
||||||
return $ isJust row
|
|
||||||
|
|
||||||
|
doesProcReturnJWT :: Text -> Text -> H.Tx P.Postgres s Bool
|
||||||
|
doesProcReturnJWT = doesProc [H.stmt|
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_catalog.pg_namespace n
|
||||||
|
JOIN pg_catalog.pg_proc p
|
||||||
|
ON pronamespace = n.oid
|
||||||
|
WHERE nspname = ?
|
||||||
|
AND proname = ?
|
||||||
|
AND pg_catalog.pg_get_function_result(p.oid) = 'jwt_claims'
|
||||||
|
|]
|
||||||
|
|
||||||
tableFromRow :: (Text, Text, Bool, Maybe Text) -> Table
|
tableFromRow :: (Text, Text, Bool, Maybe Text) -> Table
|
||||||
tableFromRow (s, n, i, a) = Table s n i (parseAcl a)
|
tableFromRow (s, n, i, a) = Table s n i (parseAcl a)
|
||||||
@@ -153,7 +167,7 @@ allRelations = do
|
|||||||
allColumns :: [Relation] -> H.Tx P.Postgres s [Column]
|
allColumns :: [Relation] -> H.Tx P.Postgres s [Column]
|
||||||
allColumns rels = do
|
allColumns rels = do
|
||||||
cols <- H.listEx $ [H.stmt|
|
cols <- H.listEx $ [H.stmt|
|
||||||
SELECT
|
SELECT DISTINCT
|
||||||
info.table_schema AS schema,
|
info.table_schema AS schema,
|
||||||
info.table_name AS table_name,
|
info.table_name AS table_name,
|
||||||
info.column_name AS name,
|
info.column_name AS name,
|
||||||
|
|||||||
+21
-44
@@ -18,55 +18,32 @@ spec = beforeAll
|
|||||||
it "hides tables that anonymous does not own" $
|
it "hides tables that anonymous does not own" $
|
||||||
get "/authors_only" `shouldRespondWith` 404
|
get "/authors_only" `shouldRespondWith` 404
|
||||||
|
|
||||||
it "indicates login failure (BasicAuth)" $ do
|
it "returns jwt functions as jwt tokens" $
|
||||||
let auth = authHeaderBasic "postgrest_test_author" "fakefake"
|
post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |]
|
||||||
request methodGet "/authors_only" [auth] ""
|
|
||||||
`shouldRespondWith` 401
|
|
||||||
|
|
||||||
it "allows users with permissions to see their tables (BasicAuth)" $ do
|
|
||||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
|
||||||
let auth = authHeaderBasic "jdoe" "1234"
|
|
||||||
request methodGet "/authors_only" [auth] ""
|
|
||||||
`shouldRespondWith` 200
|
|
||||||
|
|
||||||
it "respects database constraints for role" $
|
|
||||||
post "/postgrest/users" [json| { "id": "ssmith", "pass": "1234", "role": "SUPER_ADMIN_TRUNCATE_POWERS" } |]
|
|
||||||
`shouldRespondWith` 400
|
|
||||||
|
|
||||||
it "does not send a value when no role is provided" $ do
|
|
||||||
post "/postgrest/users" [json| { "id": "bdeey", "pass": "1234" } |]
|
|
||||||
`shouldRespondWith` 201
|
|
||||||
let auth = authHeaderBasic "jdoe" "1234"
|
|
||||||
request methodGet "/authors_only" [auth] ""
|
|
||||||
`shouldRespondWith` 200
|
|
||||||
|
|
||||||
it "recovers after 400 error with logged in user" $ do
|
|
||||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
|
||||||
let auth = authHeaderBasic "jdoe" "1234"
|
|
||||||
_ <- request methodPost "/rpc/problem" [auth] ""
|
|
||||||
request methodGet "/authors_only" [auth] ""
|
|
||||||
`shouldRespondWith` 200
|
|
||||||
|
|
||||||
it "allows users to login (JWT)" $ do
|
|
||||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
|
||||||
post "/postgrest/tokens" [json| { "id": "jdoe", "pass": "1234" } |]
|
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |]
|
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |]
|
||||||
, matchStatus = 201
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Type" <:> "application/json"]
|
, matchHeaders = ["Content-Type" <:> "application/json"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "indicates login failure (JWT)" $ do
|
it "allows users with permissions to see their tables" $ do
|
||||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
|
||||||
post "/postgrest/tokens" [json| { "id": "jdoe", "pass": "NOPE" } |]
|
|
||||||
`shouldRespondWith` ResponseMatcher {
|
|
||||||
matchBody = Just [json| {"message":"Failed authentication."} |]
|
|
||||||
, matchStatus = 401
|
|
||||||
, matchHeaders = ["Content-Type" <:> "application/json"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "allows users with permissions to see their tables (JWT)" $ do
|
|
||||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` 200
|
`shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "hides tables from users with invalid JWT" $ do
|
||||||
|
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
|
request methodGet "/authors_only" [auth] ""
|
||||||
|
`shouldRespondWith` 404
|
||||||
|
|
||||||
|
it "hides tables from users with JWT that contain no claims about role" $ do
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MKYc_lOECtB0LJOiykilAdlHodB-I0_id2qHKq35dmc"
|
||||||
|
request methodGet "/authors_only" [auth] ""
|
||||||
|
`shouldRespondWith` 404
|
||||||
|
|
||||||
|
it "recovers after 400 error with logged in user" $ do
|
||||||
|
_ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
|
_ <- request methodPost "/rpc/problem" [auth] ""
|
||||||
|
request methodGet "/authors_only" [auth] ""
|
||||||
|
`shouldRespondWith` 200
|
||||||
|
|||||||
@@ -284,11 +284,12 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
|
|
||||||
describe "Row level permission" $
|
describe "Row level permission" $
|
||||||
it "set user_id when inserting rows" $ do
|
it "set user_id when inserting rows" $ do
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||||
_ <- post "/postgrest/users" [json| { "id":"jroe", "pass": "1234", "role": "postgrest_test_author" } |]
|
_ <- post "/postgrest/users" [json| { "id":"jroe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||||
|
|
||||||
p1 <- request methodPost "/authors_only"
|
p1 <- request methodPost "/authors_only"
|
||||||
[ authHeaderBasic "jdoe" "1234", ("Prefer", "return=representation") ]
|
[ auth, ("Prefer", "return=representation") ]
|
||||||
[json| { "secret": "nyancat" } |]
|
[json| { "secret": "nyancat" } |]
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleBody p1 `shouldBe` [json| { "owner":"jdoe", "secret":"nyancat" } |]
|
simpleBody p1 `shouldBe` [json| { "owner":"jdoe", "secret":"nyancat" } |]
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ spec = around withApp $ do
|
|||||||
{matchStatus = 200}
|
{matchStatus = 200}
|
||||||
|
|
||||||
it "lists only views user has permission to see" $ do
|
it "lists only views user has permission to see" $ do
|
||||||
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
let auth = authHeaderBasic "jdoe" "1234"
|
|
||||||
|
|
||||||
request methodGet "/" [auth] ""
|
request methodGet "/" [auth] ""
|
||||||
`shouldRespondWith` [json| [
|
`shouldRespondWith` [json| [
|
||||||
|
|||||||
+1
-6
@@ -20,7 +20,6 @@ import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange,
|
|||||||
import Codec.Binary.Base64.String (encode)
|
import Codec.Binary.Base64.String (encode)
|
||||||
import Data.CaseInsensitive (CI(..))
|
import Data.CaseInsensitive (CI(..))
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Data.Functor.Identity
|
|
||||||
import Text.Regex.TDFA ((=~))
|
import Text.Regex.TDFA ((=~))
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import System.Process (readProcess)
|
import System.Process (readProcess)
|
||||||
@@ -55,10 +54,6 @@ withApp perform = do
|
|||||||
pool :: H.Pool P.Postgres
|
pool :: H.Pool P.Postgres
|
||||||
<- H.acquirePool pgSettings testPoolOpts
|
<- H.acquirePool pgSettings testPoolOpts
|
||||||
|
|
||||||
Right authenticator <- H.session pool $ do
|
|
||||||
Identity (role :: Text) <- H.tx Nothing $ H.singleEx [H.stmt|SELECT SESSION_USER|]
|
|
||||||
return role
|
|
||||||
|
|
||||||
let txSettings = Just (H.ReadCommitted, Just True)
|
let txSettings = Just (H.ReadCommitted, Just True)
|
||||||
metadata <- H.session pool $ H.tx txSettings $ do
|
metadata <- H.session pool $ H.tx txSettings $ do
|
||||||
tabs <- allTables
|
tabs <- allTables
|
||||||
@@ -80,7 +75,7 @@ withApp perform = do
|
|||||||
perform $ middle $ \req resp -> do
|
perform $ middle $ \req resp -> do
|
||||||
body <- strictRequestBody req
|
body <- strictRequestBody req
|
||||||
result <- liftIO $ H.session pool $ H.tx txSettings
|
result <- liftIO $ H.session pool $ H.tx txSettings
|
||||||
$ authenticated cfg authenticator (app dbstructure cfg authenticator body) req
|
$ runWithClaims cfg (app dbstructure cfg body) req
|
||||||
either (resp . errResponse) resp result
|
either (resp . errResponse) resp result
|
||||||
|
|
||||||
where middle = defaultMiddle False
|
where middle = defaultMiddle False
|
||||||
|
|||||||
Vendored
+25
-10
@@ -76,7 +76,7 @@ CREATE FUNCTION set_authors_only_owner() RETURNS trigger
|
|||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
AS $$
|
AS $$
|
||||||
begin
|
begin
|
||||||
NEW.owner = current_setting('user_vars.user_id');
|
NEW.owner = current_setting('postgrest.claims.id');
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
end
|
end
|
||||||
$$;
|
$$;
|
||||||
@@ -268,15 +268,6 @@ CREATE VIEW test.projects_view AS
|
|||||||
projects.client_id
|
projects.client_id
|
||||||
FROM projects;
|
FROM projects;
|
||||||
ALTER TABLE test.projects_view OWNER TO postgrest_test;
|
ALTER TABLE test.projects_view OWNER TO postgrest_test;
|
||||||
------- SAMPLE DATA -----
|
|
||||||
INSERT INTO clients VALUES (1, 'Microsoft'),(2, 'Apple');
|
|
||||||
INSERT INTO projects VALUES (1,'Windows 7', 1),(2,'Windows 10', 1),(3,'IOS', 2),(4,'OSX', 2);
|
|
||||||
INSERT INTO tasks VALUES (1,'Design w7',1),(2,'Code w7',1),(3,'Design w10',2),(4,'Code w10',2),(5,'Design IOS',3),(6,'Code IOS',3),(7,'Design OSX',4),(8,'Code OSX',4);
|
|
||||||
INSERT INTO users VALUES (1, 'Angela Martin'),(2, 'Michael Scott'),(3, 'Dwight Schrute');
|
|
||||||
INSERT INTO users_projects VALUES(1,1),(1,2),(2,3),(2,4),(3,1),(3,3);
|
|
||||||
INSERT INTO users_tasks VALUES(1,1),(1,2),(1,3),(1,4),(2,5),(2,6),(2,7),(3,1),(3,5);
|
|
||||||
INSERT INTO comments VALUES (1, 1, 2, 6, 'Needs to be delivered ASAP');
|
|
||||||
----------------
|
|
||||||
|
|
||||||
CREATE SEQUENCE items_id_seq
|
CREATE SEQUENCE items_id_seq
|
||||||
START WITH 1
|
START WITH 1
|
||||||
@@ -298,6 +289,13 @@ CREATE FUNCTION test.getitemrange(min bigint, max bigint) RETURNS SETOF test.ite
|
|||||||
$$ LANGUAGE SQL;
|
$$ LANGUAGE SQL;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TYPE public.jwt_claims AS (role text, id text);
|
||||||
|
CREATE FUNCTION test.login(id text, pass text)
|
||||||
|
RETURNS public.jwt_claims
|
||||||
|
SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
SELECT rolname::text, id::text FROM postgrest.auth WHERE id = id AND pass = pass;
|
||||||
|
$$ LANGUAGE SQL;
|
||||||
|
|
||||||
CREATE FUNCTION test.sayhello(name text) RETURNS text AS $$
|
CREATE FUNCTION test.sayhello(name text) RETURNS text AS $$
|
||||||
SELECT 'Hello, ' || $1;
|
SELECT 'Hello, ' || $1;
|
||||||
@@ -663,6 +661,10 @@ REVOKE ALL ON FUNCTION getitemrange(bigint, bigint) FROM postgrest_test;
|
|||||||
GRANT EXECUTE ON FUNCTION getitemrange(bigint, bigint) TO postgrest_test;
|
GRANT EXECUTE ON FUNCTION getitemrange(bigint, bigint) TO postgrest_test;
|
||||||
GRANT EXECUTE ON FUNCTION getitemrange(bigint, bigint) TO postgrest_anonymous;
|
GRANT EXECUTE ON FUNCTION getitemrange(bigint, bigint) TO postgrest_anonymous;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION login(text, text) FROM PUBLIC;
|
||||||
|
REVOKE ALL ON FUNCTION login(text, text) FROM postgrest_test;
|
||||||
|
GRANT EXECUTE ON FUNCTION login(text, text) TO postgrest_test;
|
||||||
|
GRANT EXECUTE ON FUNCTION login(text, text) TO postgrest_anonymous;
|
||||||
|
|
||||||
REVOKE ALL ON FUNCTION sayhello(text) FROM PUBLIC;
|
REVOKE ALL ON FUNCTION sayhello(text) FROM PUBLIC;
|
||||||
REVOKE ALL ON FUNCTION sayhello(text) FROM postgrest_test;
|
REVOKE ALL ON FUNCTION sayhello(text) FROM postgrest_test;
|
||||||
@@ -758,3 +760,16 @@ SET search_path = private, pg_catalog;
|
|||||||
REVOKE ALL ON TABLE articles FROM PUBLIC;
|
REVOKE ALL ON TABLE articles FROM PUBLIC;
|
||||||
REVOKE ALL ON TABLE articles FROM postgrest_test;
|
REVOKE ALL ON TABLE articles FROM postgrest_test;
|
||||||
GRANT ALL ON TABLE articles TO postgrest_test;
|
GRANT ALL ON TABLE articles TO postgrest_test;
|
||||||
|
|
||||||
|
SET search_path = test, private, postgrest, public, pg_catalog;
|
||||||
|
|
||||||
|
------- SAMPLE DATA -----
|
||||||
|
INSERT INTO clients VALUES (1, 'Microsoft'),(2, 'Apple');
|
||||||
|
INSERT INTO projects VALUES (1,'Windows 7', 1),(2,'Windows 10', 1),(3,'IOS', 2),(4,'OSX', 2);
|
||||||
|
INSERT INTO tasks VALUES (1,'Design w7',1),(2,'Code w7',1),(3,'Design w10',2),(4,'Code w10',2),(5,'Design IOS',3),(6,'Code IOS',3),(7,'Design OSX',4),(8,'Code OSX',4);
|
||||||
|
INSERT INTO users VALUES (1, 'Angela Martin'),(2, 'Michael Scott'),(3, 'Dwight Schrute');
|
||||||
|
INSERT INTO users_projects VALUES(1,1),(1,2),(2,3),(2,4),(3,1),(3,3);
|
||||||
|
INSERT INTO users_tasks VALUES(1,1),(1,2),(1,3),(1,4),(2,5),(2,6),(2,7),(3,1),(3,5);
|
||||||
|
INSERT INTO comments VALUES (1, 1, 2, 6, 'Needs to be delivered ASAP');
|
||||||
|
INSERT INTO postgrest.auth (id, pass, rolname) VALUES ('jdoe', '1234', 'postgrest_test_author');
|
||||||
|
----------------
|
||||||
|
|||||||
Reference in New Issue
Block a user