Compare commits

...
10 Commits
13 changed files with 105 additions and 31 deletions
+9
View File
@@ -3,6 +3,15 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
## [0.2.10.0] - 2015-06-03
### Added
- Full text search, eg `/foo?text_vector=@@.bar`
- Include auth id as well as db role to views (for row-level security)
## [0.2.9.1] - 2015-05-20
### Fixed
- Put -Werror behind a cabal flag (for CI) so Hackage accepts package
## [0.2.9.0] - 2015-05-20 ## [0.2.9.0] - 2015-05-20
### Added ### Added
- Return range headers in PATCH - Return range headers in PATCH
+9 -7
View File
@@ -20,7 +20,7 @@ your own projects.
### Usage ### Usage
Download the binary ([OS X](http://bin.begriffs.com/dbapi/osx/postgrest-0.2.9.0.tar.xz) / [Linux](http://bin.begriffs.com/dbapi/heroku/postgrest-0.2.9.0.tar.xz)) and invoke like so: Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest)) and invoke like so:
```bash ```bash
postgrest --db-host localhost --db-port 5432 \ postgrest --db-host localhost --db-port 5432 \
@@ -75,12 +75,14 @@ Other optimizations are possible, and some are outlined in the
### Security ### Security
PostgREST handles authentication (HTTP Basic over SSL) and delegates PostgREST handles authentication (HTTP Basic over SSL or [JSON Web
authorization to the role information defined in the database. This Tokens](https://github.com/begriffs/postgrest/wiki/Security-and-Permissions#json-web-tokens))
ensures there is a single declarative source of truth for security. and delegates authorization to the role information defined in the
When dealing with the database the server assumes the identity of database. This ensures there is a single declarative source of truth
the currently authenticated user, and for the duration of the for security. When dealing with the database the server assumes
connection cannot do anything the user themselves couldn't. the identity of the currently authenticated user, and for the
duration of the connection cannot do anything the user themselves
couldn't.
Postgres 9.5 will soon support true [row-level Postgres 9.5 will soon support true [row-level
security](http://michael.otacoo.com/postgresql-2/postgres-9-5-feature-highlight-row-level-security/). security](http://michael.otacoo.com/postgresql-2/postgres-9-5-feature-highlight-row-level-security/).
+1 -1
View File
@@ -10,7 +10,7 @@
}, },
"POSTGREST_VER": { "POSTGREST_VER": {
"description": "Version of PostgREST to deploy", "description": "Version of PostgREST to deploy",
"value": "0.2.9.0" "value": "0.2.10.0"
}, },
"DB_NAME": { "DB_NAME": {
"description": "Database name", "description": "Database name",
+1 -1
View File
@@ -2,7 +2,7 @@ name: postgrest
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for the tables and views, supporting all HTTP verbs that security for the tables and views, supporting all HTTP verbs that security
permits. permits.
version: 0.2.9.1 version: 0.2.10.0
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
+2 -2
View File
@@ -120,9 +120,9 @@ app conf reqBody req =
login <- signInRole (cs $ userId u) login <- signInRole (cs $ userId u)
(cs $ userPass u) (cs $ userPass u)
case login of case login of
LoginSuccess role -> LoginSuccess role uid ->
return $ responseLBS status201 [ jsonH ] $ return $ responseLBS status201 [ jsonH ] $
encode . object $ [("token", String $ tokenJWT jwtSecret (cs $ userId u) role)] encode . object $ [("token", String $ tokenJWT jwtSecret uid role)]
_ -> return $ responseLBS status401 [jsonH] $ _ -> return $ responseLBS status401 [jsonH] $
encode . object $ [("message", String "Failed authentication.")] encode . object $ [("message", String "Failed authentication.")]
+18 -5
View File
@@ -40,12 +40,13 @@ instance ToJSON AuthUser where
, "role" .= userRole u ] , "role" .= userRole u ]
type DbRole = Text type DbRole = Text
type UserId = Text
data LoginAttempt = data LoginAttempt =
NoCredentials NoCredentials
| MalformedAuth | MalformedAuth
| LoginFailed | LoginFailed
| LoginSuccess DbRole | LoginSuccess DbRole UserId
deriving (Eq, Show) deriving (Eq, Show)
checkPass :: Text -> Text -> Bool checkPass :: Text -> Text -> Bool
@@ -57,6 +58,15 @@ setRole role = H.unitEx $ B.Stmt ("set role " <> cs (pgFmtLit role)) V.empty Tru
resetRole :: H.Tx P.Postgres s () resetRole :: H.Tx P.Postgres s ()
resetRole = H.unitEx [H.stmt|reset role|] resetRole = H.unitEx [H.stmt|reset role|]
setUserId :: Text -> H.Tx P.Postgres s ()
setUserId uid = if uid /= "" then
H.unitEx $ B.Stmt ("set 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 -> Text -> H.Tx P.Postgres s () addUser :: Text -> Text -> Text -> H.Tx P.Postgres s ()
addUser identity pass role = do addUser identity pass role = do
let Just hashed = unsafePerformIO $ hashPasswordUsingPolicy fastBcryptHashingPolicy (cs pass) let Just hashed = unsafePerformIO $ hashPasswordUsingPolicy fastBcryptHashingPolicy (cs pass)
@@ -66,20 +76,23 @@ addUser identity pass role = do
signInRole :: Text -> Text -> H.Tx P.Postgres s LoginAttempt signInRole :: Text -> Text -> H.Tx P.Postgres s LoginAttempt
signInRole user pass = do signInRole user pass = do
u <- H.maybeEx $ [H.stmt|select pass, rolname from postgrest.auth where id = ?|] user u <- H.maybeEx $ [H.stmt|select id, pass, rolname from postgrest.auth where id = ?|] user
return $ maybe LoginFailed (\r -> return $ maybe LoginFailed (\r ->
let (hashed, role) = r in let (uid, hashed, role) = r in
if checkPass hashed pass if checkPass hashed pass
then LoginSuccess role then LoginSuccess role uid
else LoginFailed else LoginFailed
) u ) u
signInWithJWT :: Text -> Text -> LoginAttempt signInWithJWT :: Text -> Text -> LoginAttempt
signInWithJWT secret input = case maybeRole of signInWithJWT secret input = case maybeRole of
Just (Just (String role)) -> LoginSuccess $ cs role Just (Just (String role)) -> case maybeUserId of
Just (Just (String uid)) -> LoginSuccess (cs role) (cs uid)
_ -> LoginFailed
_ -> LoginFailed _ -> LoginFailed
where where
maybeRole = (Data.Map.lookup "role" <$> claims) ::Maybe (Maybe Value) maybeRole = (Data.Map.lookup "role" <$> claims) ::Maybe (Maybe Value)
maybeUserId = (Data.Map.lookup "id" <$> claims) ::Maybe (Maybe Value)
claims = JWT.unregisteredClaims <$> JWT.claims <$> decoded claims = JWT.unregisteredClaims <$> JWT.claims <$> decoded
decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input
+7 -5
View File
@@ -20,7 +20,7 @@ import Network.Wai (Application, requestHeaders, responseLBS, rawPathInfo,
import Network.URI (URI(..), parseURI) import Network.URI (URI(..), parseURI)
import PostgREST.Config (AppConfig(..)) import PostgREST.Config (AppConfig(..))
import PostgREST.Auth (LoginAttempt(..), signInRole, signInWithJWT, setRole, resetRole) import PostgREST.Auth (LoginAttempt(..), signInRole, signInWithJWT, setRole, resetRole, setUserId, resetUserId)
import Codec.Binary.Base64.String (decode) import Codec.Binary.Base64.String (decode)
import Prelude import Prelude
@@ -35,8 +35,8 @@ authenticated conf app req = do
return $ responseLBS status400 [] "Malformed basic auth header" return $ responseLBS status400 [] "Malformed basic auth header"
LoginFailed -> LoginFailed ->
return $ responseLBS status401 [] "Invalid username or password" return $ responseLBS status401 [] "Invalid username or password"
LoginSuccess role -> if role /= currentRole then runInRole role else app req LoginSuccess role uid -> if role /= currentRole then runInRole role uid else app req
NoCredentials -> if anon /= currentRole then runInRole anon else app req NoCredentials -> if anon /= currentRole then runInRole anon "" else app req
where where
jwtSecret = cs $ configJwtSecret conf jwtSecret = cs $ configJwtSecret conf
@@ -54,11 +54,13 @@ authenticated conf app req = do
return $ signInWithJWT jwtSecret jwt return $ signInWithJWT jwtSecret jwt
_ -> return NoCredentials _ -> return NoCredentials
runInRole :: Text -> H.Tx P.Postgres s Response runInRole :: Text -> Text -> H.Tx P.Postgres s Response
runInRole r = do runInRole r uid = do
setUserId uid
setRole r setRole r
res <- app req res <- app req
resetRole resetRole
resetUserId
return res return res
+2
View File
@@ -175,6 +175,7 @@ wherePred (col, predicate) =
"like" -> unknownLiteral $ T.map star value "like" -> unknownLiteral $ T.map star value
"ilike" -> unknownLiteral $ T.map star value "ilike" -> unknownLiteral $ T.map star value
"in" -> "(" <> T.intercalate ", " (map unknownLiteral $ T.split (==',') value) <> ") " "in" -> "(" <> T.intercalate ", " (map unknownLiteral $ T.split (==',') value) <> ") "
"@@" -> "to_tsquery(" <> unknownLiteral value <> ") "
_ -> unknownLiteral value _ -> unknownLiteral value
op = case opCode of op = case opCode of
@@ -189,6 +190,7 @@ wherePred (col, predicate) =
"in" -> "in" "in" -> "in"
"is" -> "is" "is" -> "is"
"isnot" -> "is not" "isnot" -> "is not"
"@@" -> "@@"
_ -> "=" _ -> "="
orderParse :: Net.Query -> [OrderTerm] orderParse :: Net.Query -> [OrderTerm]
+20
View File
@@ -274,3 +274,23 @@ spec = afterAll_ resetDb $ around withApp $ do
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
[json| { id: 99 } |] [json| { id: 99 } |]
`shouldRespondWith` [json| [{id:99}] |] `shouldRespondWith` [json| [{id:99}] |]
describe "Row level permission" $
it "set user_id when inserting rows" $ do
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
_ <- post "/postgrest/users" [json| { "id":"jroe", "pass": "1234", "role": "postgrest_test_author" } |]
p1 <- request methodPost "/authors_only"
[ authHeaderBasic "jdoe" "1234", ("Prefer", "return=representation") ]
[json| { "secret": "nyancat" } |]
liftIO $ do
simpleBody p1 `shouldBe` [json| { "owner":"jdoe", "secret":"nyancat" } |]
simpleStatus p1 `shouldBe` created201
p2 <- request methodPost "/authors_only"
-- jwt token for jroe
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.YuF_VfmyIxWyuceT7crnNKEprIYXsJAyXid3rjPjIow", ("Prefer", "return=representation") ]
[json| { "secret": "lolcat", "owner": "hacker" } |]
liftIO $ do
simpleBody p2 `shouldBe` [json| { "owner":"jroe", "secret":"lolcat" } |]
simpleStatus p2 `shouldBe` created201
+4
View File
@@ -56,6 +56,10 @@ spec =
get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith` get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`
"[{\"k\":\"xyyx\",\"extra\":\"u\"},{\"k\":\"xYYx\",\"extra\":\"v\"}]" "[{\"k\":\"xyyx\",\"extra\":\"u\"},{\"k\":\"xYYx\",\"extra\":\"v\"}]"
it "matches with tsearch @@" $
get "/tsearch?text_search_vector=@@.foo" `shouldRespondWith`
"[{\"text_search_vector\":\"'bar':2 'foo':1\"}]"
describe "ordering response" $ do describe "ordering response" $ do
it "by a column asc" $ it "by a column asc" $
get "/items?id=lte.2&order=id.asc" get "/items?id=lte.2&order=id.asc"
+1
View File
@@ -22,6 +22,7 @@ spec = around withApp $ do
, {"schema":"1","name":"menagerie","insertable":true} , {"schema":"1","name":"menagerie","insertable":true}
, {"schema":"1","name":"no_pk","insertable":true} , {"schema":"1","name":"no_pk","insertable":true}
, {"schema":"1","name":"simple_pk","insertable":true} , {"schema":"1","name":"simple_pk","insertable":true}
, {"schema":"1","name":"tsearch","insertable":true}
] |] ] |]
{matchStatus = 200} {matchStatus = 200}
+1 -1
View File
@@ -79,7 +79,7 @@ spec = around dbWithSchema $ do
addUser user pass role conn addUser user pass role conn
return conn) $ do return conn) $ do
it "accepts correct credentials and return the role" $ \conn -> it "accepts correct credentials and return the role" $ \conn ->
signInRole user pass conn `shouldReturn` LoginSuccess role signInRole user pass conn `shouldReturn` LoginSuccess role user
it "returns nothing with bad creds" $ \conn -> do it "returns nothing with bad creds" $ \conn -> do
signInRole "not-a-user" pass conn `shouldReturn` LoginFailed signInRole "not-a-user" pass conn `shouldReturn` LoginFailed
+30 -9
View File
@@ -72,6 +72,18 @@ $$;
ALTER FUNCTION postgrest.update_owner() OWNER TO postgrest_test; ALTER FUNCTION postgrest.update_owner() OWNER TO postgrest_test;
CREATE FUNCTION set_authors_only_owner() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
NEW.owner = current_setting('user_vars.user_id');
RETURN NEW;
end
$$;
ALTER FUNCTION postgrest.set_authors_only_owner() OWNER TO postgrest_test;
SET search_path = "1", pg_catalog; SET search_path = "1", pg_catalog;
SET default_tablespace = ''; SET default_tablespace = '';
@@ -80,6 +92,7 @@ SET default_with_oids = false;
CREATE TABLE authors_only ( CREATE TABLE authors_only (
owner character varying NOT NULL,
secret character varying NOT NULL secret character varying NOT NULL
); );
@@ -212,6 +225,12 @@ CREATE TABLE json
ALTER TABLE "1".json OWNER TO postgrest_test; ALTER TABLE "1".json OWNER TO postgrest_test;
CREATE TABLE tsearch (
text_search_vector tsvector
);
ALTER TABLE "1".tsearch OWNER TO postgrest_test;
SET search_path = postgrest, pg_catalog; SET search_path = postgrest, pg_catalog;
@@ -299,14 +318,8 @@ INSERT INTO items (id) VALUES (1);
SELECT pg_catalog.setval('items_id_seq', 1, true); SELECT pg_catalog.setval('items_id_seq', 1, true);
INSERT INTO tsearch (text_search_vector) VALUES ('''bar'':2 ''foo'':1');
INSERT INTO tsearch (text_search_vector) VALUES ('''baz'':1 ''qux'':2');
SET search_path = postgrest, pg_catalog; SET search_path = postgrest, pg_catalog;
@@ -327,7 +340,8 @@ SET search_path = "1", pg_catalog;
ALTER TABLE ONLY authors_only ALTER TABLE ONLY authors_only
ADD CONSTRAINT authors_only_pkey PRIMARY KEY (secret); ADD CONSTRAINT authors_only_pkey PRIMARY KEY (secret);
CREATE TRIGGER secrets_owner_track BEFORE INSERT OR UPDATE ON authors_only FOR EACH ROW EXECUTE PROCEDURE postgrest.set_authors_only_owner();
ALTER TABLE ONLY auto_incrementing_pk ALTER TABLE ONLY auto_incrementing_pk
@@ -491,6 +505,13 @@ GRANT ALL ON TABLE json TO postgrest_test;
GRANT ALL ON TABLE json TO postgrest_anonymous; GRANT ALL ON TABLE json TO postgrest_anonymous;
REVOKE ALL ON TABLE tsearch FROM PUBLIC;
REVOKE ALL ON TABLE tsearch FROM postgrest_test;
GRANT ALL ON TABLE tsearch TO postgrest_test;
GRANT ALL ON TABLE tsearch TO postgrest_anonymous;
SET search_path = postgrest, pg_catalog; SET search_path = postgrest, pg_catalog;