sproxy2 1.94.1 → 1.95.0
raw patch · 6 files changed
+156/−40 lines, 6 files
Files
- ChangeLog.md +9/−1
- README.md +52/−20
- sproxy2.cabal +2/−1
- src/Sproxy/Application.hs +41/−4
- src/Sproxy/Application/Access.hs +23/−0
- src/Sproxy/Server/DB.hs +29/−14
ChangeLog.md view
@@ -1,5 +1,13 @@ For differences with the original Sproxy scroll down. +1.95.0+======++ * Add end-point for checking access in a bunch (`/.sproxy/access`).++ * Respond with 502 (Bad Gateway) on any backend error.+ Previously it was 500 (Internal Server Error).+ 1.94.1 ====== @@ -122,5 +130,5 @@ This can be used in load-balancing multiple Sproxy2 instances. * Configuration file has changed. It's still YAML, but some options are renamed, removed or added.- Have a look at well-documented [sproxy.yml.example](./sproxy.yml.example)+ Have a look at well-documented [sproxy.example.yml](./sproxy.example.yml)
README.md view
@@ -140,34 +140,38 @@ - SQL wildcards (`_` and `%`) are supported for emails, paths (this _will_ change in future versions). -HTTP headers passed to the back-end server-------------------------------------------+Checking access in a bunch+-------------------------- -All Sproxy headers are UTF8-encoded.+There is an API end-point for checking access rights in a single POST query:+`/.sproxy/access`. Users should be authenticated to use this end-point,+otherwise the respond will be HTTP 511. +The request body shall be a JSON object like this: -header | value--------------------- | ------`From:` | visitor's email address, lower case-`X-Groups:` | all groups that granted access to this resource, separated by commas (see the note below)-`X-Given-Name:` | the visitor's given (first) name-`X-Family-Name:` | the visitor's family (last) name-`X-Forwarded-Proto:` | the visitor's protocol of an HTTP request, always `https`-`X-Forwarded-For` | the visitor's IP address (added to the end of the list if header is already present in client request)+```json+{+ "tag1": {"path": "/foo", "method": "GET"},+ "tag2": {"path": "/bar", "method": "GET"}+}+``` +And the respond will contain a JSON array with tag matching path and method+pairs allowed to the user. For example: -`X-Groups` denotes an intersection of the groups the visitor belongs to and the groups that granted access:+```sh+$ curl -d '{"foo": {"path":"/get", "method":"GET"}, "bar": {"path":"/post", "method":"POST"}}' -XPOST -k 'https://example.ru:8443/.sproxy/access' ...+["foo","bar"] -Visitor's groups | Granted groups | `X-Groups`----------------- | -------------- | ----------all | all, devops | all-all, devops | all | all-all, devops | all, devops | all,devops-all, devops | devops | devops-devops | all, devops | devops-devops | all | Access denied+$ curl -d '{"foo": {"path":"/get", "method":"POST"}, "bar": {"path":"/post", "method":"POST"}}' -XPOST -k 'https://example.ru:8443/.sproxy/access' ...+["bar"] +$ curl -d '{"foo": {"path":"/", "method":"POST"}, "bar": {"path":"/post", "method":"GET"}}' -XPOST -k 'https://example.ru:8443/.sproxy/access' ...+[] +```++ Logout ------ @@ -186,6 +190,34 @@ User-agent: * Disallow: / ```+++HTTP headers passed to the back-end server+------------------------------------------++All Sproxy headers are UTF8-encoded.+++header | value+-------------------- | -----+`From:` | visitor's email address, lower case+`X-Groups:` | all groups that granted access to this resource, separated by commas (see the note below)+`X-Given-Name:` | the visitor's given (first) name+`X-Family-Name:` | the visitor's family (last) name+`X-Forwarded-Proto:` | the visitor's protocol of an HTTP request, always `https`+`X-Forwarded-For` | the visitor's IP address (added to the end of the list if header is already present in client request)+++`X-Groups` denotes an intersection of the groups the visitor belongs to and the groups that granted access:++Visitor's groups | Granted groups | `X-Groups`+---------------- | -------------- | ---------+all | all, devops | all+all, devops | all | all+all, devops | all, devops | all,devops+all, devops | devops | devops+devops | all, devops | devops+devops | all | Access denied Requirements
sproxy2.cabal view
@@ -1,5 +1,5 @@ name: sproxy2-version: 1.94.1+version: 1.95.0 synopsis: Secure HTTP proxy for authenticating users via OAuth2 description: Sproxy is secure by default. No requests makes it to the backend@@ -32,6 +32,7 @@ main-is: Main.hs other-modules: Sproxy.Application+ Sproxy.Application.Access Sproxy.Application.Cookie Sproxy.Application.OAuth2 Sproxy.Application.OAuth2.Common
src/Sproxy/Application.hs view
@@ -27,11 +27,11 @@ import Foreign.C.Types (CTime(..)) import Network.HTTP.Client.Conduit (bodyReaderSource) import Network.HTTP.Conduit (requestBodySourceChunkedIO, requestBodySourceIO)-import Network.HTTP.Types (RequestHeaders, ResponseHeaders, methodGet)+import Network.HTTP.Types (RequestHeaders, ResponseHeaders, methodGet, methodPost) import Network.HTTP.Types.Header ( hConnection, hContentLength, hContentType, hCookie, hLocation, hTransferEncoding )-import Network.HTTP.Types.Status ( Status(..), badRequest400, forbidden403, found302,- internalServerError500, methodNotAllowed405, movedPermanently301,+import Network.HTTP.Types.Status ( Status(..), badGateway502, badRequest400, forbidden403,+ found302, internalServerError500, methodNotAllowed405, movedPermanently301, networkAuthenticationRequired511, notFound404, ok200, seeOther303, temporaryRedirect307 ) import Network.Socket (NameInfoFlag(NI_NUMERICHOST), getNameInfo) import Network.Wai.Conduit (sourceRequestBody, responseSource)@@ -39,6 +39,7 @@ import System.Posix.Time (epochTime) import Text.InterpolatedString.Perl6 (qc) import Web.Cookie (Cookies, parseCookies, renderCookies)+import qualified Data.Aeson as JSON import qualified Network.HTTP.Client as BE import qualified Network.Wai as W import qualified Web.Cookie as WC@@ -48,7 +49,7 @@ getGivenNameUtf8 ) import Sproxy.Application.OAuth2.Common (OAuth2Client(..)) import Sproxy.Config(BackendConf(..))-import Sproxy.Server.DB (Database, userExists, userGroups)+import Sproxy.Server.DB (Database, userAccess, userExists, userGroups) import qualified Sproxy.Application.State as State import qualified Sproxy.Logging as Log @@ -81,12 +82,22 @@ ["robots.txt"] -> get robots req resp (".sproxy":proxy) -> case proxy of+ ["logout"] -> get (logout key cookieName cookieDomain) req resp+ ["oauth2", provider] -> case HM.lookup provider oa2 of Nothing -> notFound "OAuth2 provider" req resp Just oa2c -> get (oauth2callback key db (provider, oa2c) be) req resp++ ["access"] -> do+ now <- Just <$> epochTime+ case extractCookie key now cookieName req of+ Nothing -> authenticationRequired key oa2 req resp+ Just (authCookie, _) -> post (checkAccess db authCookie) req resp+ _ -> notFound "proxy" req resp+ _ -> do now <- Just <$> epochTime case extractCookie key now cookieName req of@@ -195,6 +206,20 @@ setCookies cs = insert hCookie (toByteString . renderCookies $ cs) +checkAccess :: Database -> AuthCookie -> W.Application+checkAccess db authCookie req resp = do+ let email = getEmail . acUser $ authCookie+ domain = decodeUtf8 . fromJust $ requestDomain req+ body <- W.strictRequestBody req+ case JSON.eitherDecode' body of+ Left err -> badRequest err req resp+ Right inq -> do+ Log.debug $ "access <<< " ++ show inq+ tags <- userAccess db email domain inq+ Log.debug $ "access >>> " ++ show tags+ resp $ W.responseLBS ok200 [(hContentType, "application/json")] (JSON.encode tags)++ -- XXX If something seems strange, think about HTTP/1.1 <-> HTTP/1.0. -- FIXME For HTTP/1.0 backends we might need an option -- FIXME in config file. HTTP Client does HTTP/1.1 by default.@@ -358,12 +383,16 @@ logException :: W.Middleware logException app req resp = catches (app req resp) [+ Handler badGateway, Handler internalError ] where internalError :: SomeException -> IO W.ResponseReceived internalError = response internalServerError500 + badGateway :: BE.HttpException -> IO W.ResponseReceived+ badGateway = response badGateway502+ response :: Exception e => Status -> e -> IO W.ResponseReceived response st e = do Log.error $ show (statusCode st) ++ " " ++ unpack (statusMessage st)@@ -378,6 +407,14 @@ | otherwise = do Log.warn $ "405 Method Not Allowed: " ++ showReq req resp $ W.responseLBS methodNotAllowed405 [("Allow", "GET")] "Method Not Allowed"+++post :: W.Middleware+post app req resp+ | W.requestMethod req == methodPost = app req resp+ | otherwise = do+ Log.warn $ "405 Method Not Allowed: " ++ showReq req+ resp $ W.responseLBS methodNotAllowed405 [("Allow", "POST")] "Method Not Allowed" redirectURL :: W.Request -> Text -> ByteString
+ src/Sproxy/Application/Access.hs view
@@ -0,0 +1,23 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}++module Sproxy.Application.Access (+ Inquiry+, Question(..)+) where++import Data.Aeson (FromJSON)+import Data.HashMap.Strict (HashMap)+import Data.Text (Text)+import GHC.Generics (Generic)+++data Question = Question {+ path :: Text+, method :: Text+} deriving (Generic, Show)++instance FromJSON Question++type Inquiry = HashMap Text Question+
src/Sproxy/Server/DB.hs view
@@ -3,6 +3,7 @@ module Sproxy.Server.DB ( Database , DataSource(..)+, userAccess , userExists , userGroups , start@@ -10,18 +11,20 @@ import Control.Concurrent (forkIO, threadDelay) import Control.Exception (SomeException, bracket, catch, finally)-import Control.Monad (forever, void)+import Control.Monad (filterM, forever, void) import Data.ByteString.Char8 (pack) import Data.Pool (Pool, createPool, withResource) import Data.Text (Text, toLower, unpack) import Data.Yaml (decodeFileEither) import Database.SQLite.Simple (NamedParam((:=))) import Text.InterpolatedString.Perl6 (q, qc)+import qualified Data.HashMap.Strict as HM import qualified Database.PostgreSQL.Simple as PG import qualified Database.SQLite.Simple as SQLite import Sproxy.Server.DB.DataFile ( DataFile(..), GroupMember(..), GroupPrivilege(..), PrivilegeRule(..) )+import qualified Sproxy.Application.Access as A import qualified Sproxy.Logging as Log @@ -61,24 +64,36 @@ return $ head r -userGroups :: Database -> Text -> Text -> Text -> Text -> IO [Text]-userGroups db email domain path method =- withResource db $ \c -> fmap SQLite.fromOnly <$> SQLite.queryNamed c [q|- SELECT gm."group" FROM group_privilege gp JOIN group_member gm ON gm."group" = gp."group"- WHERE :email LIKE gm.email- AND gp.domain = :domain- AND gp.privilege IN (- SELECT privilege FROM privilege_rule- WHERE domain = :domain- AND :path LIKE path- AND method = :method- ORDER BY length(path) - length(replace(path, '/', '')) DESC LIMIT 1- )+userGroups_ :: SQLite.Connection -> Text -> Text -> Text -> Text -> IO [Text]+userGroups_ c email domain path method =+ fmap SQLite.fromOnly <$> SQLite.queryNamed c [q|+ SELECT gm."group" FROM group_privilege gp JOIN group_member gm ON gm."group" = gp."group"+ WHERE :email LIKE gm.email+ AND gp.domain = :domain+ AND gp.privilege IN (+ SELECT privilege FROM privilege_rule+ WHERE domain = :domain+ AND :path LIKE path+ AND method = :method+ ORDER BY length(path) - length(replace(path, '/', '')) DESC LIMIT 1+ ) |] [ ":email" := email -- XXX always in lower case , ":domain" := toLower domain , ":path" := path , ":method" := method -- XXX case-sensitive by RFC2616 ]+++userAccess :: Database -> Text -> Text -> A.Inquiry -> IO [Text]+userAccess db email domain inq = do+ let permitted c (_, qn) =+ not . null <$> userGroups_ c email domain (A.path qn) (A.method qn)+ map fst <$> withResource db (\c -> filterM (permitted c) (HM.toList inq))+++userGroups :: Database -> Text -> Text -> Text -> Text -> IO [Text]+userGroups db email domain path method =+ withResource db $ \c -> userGroups_ c email domain path method populate :: Database -> Maybe DataSource -> IO ()