goggles-gcs (empty) → 0.1.0.0
raw patch · 9 files changed
+382/−0 lines, 9 filesdep +aesondep +basedep +bytestringsetup-changed
Dependencies added: aeson, base, bytestring, cryptonite, exceptions, goggles, memory, mtl, req, text, unix-time
Files
- LICENSE +30/−0
- README.md +5/−0
- Setup.hs +2/−0
- goggles-gcs.cabal +51/−0
- src/Network/Goggles/Auth/GCP.hs +11/−0
- src/Network/Goggles/Auth/GCP/JWT.hs +73/−0
- src/Network/Goggles/Auth/GCP/TokenExchange.hs +145/−0
- src/Network/Goggles/GCS.hs +40/−0
- src/Network/Goggles/Types/GCP.hs +25/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Author name here (c) 2018++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Author name here nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,5 @@+# goggles-gcs++[](https://travis-ci.org/ocramz/goggles-gcs)++TODO Description.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ goggles-gcs.cabal view
@@ -0,0 +1,51 @@+name: goggles-gcs+version: 0.1.0.0+synopsis: `goggles` interface to Google Cloud Storage+description: `goggles` interface to Google Cloud Storage+homepage: https://github.com/ocramz/goggles-gcs+license: BSD3+license-file: LICENSE+author: Marco Zocca+maintainer: zocca.marco gmail+copyright: 2018 Marco Zocca+category: Acme+build-type: Simple+extra-source-files: README.md+cabal-version: >=1.10+tested-with: GHC == 8.0.2, GHC == 8.2.2++library+ default-language: Haskell2010+ ghc-options: -Wall+ hs-source-dirs: src+ exposed-modules: Network.Goggles.GCS+ other-modules: Network.Goggles.Auth.GCP+ Network.Goggles.Types.GCP+ Network.Goggles.Auth.GCP.JWT+ Network.Goggles.Auth.GCP.TokenExchange+ build-depends: base >= 4.7 && < 5+ , goggles+ , aeson+ , unix-time+ , mtl+ , text+ , bytestring+ , cryptonite+ , memory+ , exceptions+ , req++-- test-suite spec+-- default-language: Haskell2010+-- ghc-options: -Wall+-- type: exitcode-stdio-1.0+-- hs-source-dirs: test+-- main-is: Spec.hs+-- build-depends: base+-- , goggles-gcs+-- , hspec+-- , QuickCheck++source-repository head+ type: git+ location: https://github.com/ocramz/goggles
+ src/Network/Goggles/Auth/GCP.hs view
@@ -0,0 +1,11 @@+module Network.Goggles.Auth.GCP+ (+ GCP(..), scopesDefault,+ getObject,+ getObjectMetadata,+ listObjects,+ putObject+ )+where++import Network.Goggles.Auth.GCP.TokenExchange
+ src/Network/Goggles/Auth/GCP/JWT.hs view
@@ -0,0 +1,73 @@+{-# language OverloadedStrings, FlexibleContexts, RecordWildCards #-}+module Network.Goggles.Auth.GCP.JWT where++import qualified Data.ByteString.Lazy as LB+import qualified Data.ByteString.Char8 as B8+import qualified Data.Text as T+++import qualified Data.Aeson as J+import Data.Aeson ((.=))+import qualified Data.Aeson.Types as J (Pair)++import Data.Monoid ((<>))++import Data.UnixTime (getUnixTime, utSeconds, UnixTime(..))+import Foreign.C.Types++import Control.Monad.Except+import Control.Monad.Trans (liftIO)+import Control.Monad.Catch++import qualified Crypto.Hash.Algorithms as CR+import qualified Crypto.PubKey.RSA.PKCS15 as CR (signSafer) +import qualified Crypto.Random.Types as CR+++import Data.ByteArray+import Data.ByteArray.Encoding++import Network.Goggles+import Network.Goggles.Types.GCP+++++-- | Returns a bytestring with the signed JWT-encoded token request. +-- adapted from https://github.com/brendanhay/gogol/blob/master/gogol/src/Network/Google/Auth/ServiceAccount.hs+encodeBearerJWT :: (MonadThrow m, CR.MonadRandom m, MonadIO m) =>+ GCPServiceAccount+ -> GCPTokenOptions+ -> m B8.ByteString+encodeBearerJWT s opts = do+ i <- input <$> liftIO getUnixTime+ r <- CR.signSafer (Just CR.SHA256) (_servicePrivateKey s) i+ either failure (pure . concat' i) r+ where+ concat' i x = i <> "." <> signature (base64 x)+ failure e = throwM $ CryptoSignError (show e)+ signature bs = case B8.unsnoc bs of+ Nothing -> mempty+ Just (bs', x)+ | x == '=' -> bs'+ | otherwise -> bs+ input t = header <> "." <> payload+ where+ header = base64Encode+ [ "alg" .= ("RS256" :: T.Text)+ , "typ" .= ("JWT" :: T.Text) ]+ payload = base64Encode $+ [ "aud" .= T.pack "https://www.googleapis.com/oauth2/v4/token" + , "scope" .= (T.intercalate " " $ _tokenOptionsScopes opts)+ , "iat" .= toT (utSeconds t)+ , "exp" .= toT (CTime 3600 + utSeconds t)+ , "iss" .= _serviceEmail s+ ] <> maybe [] (\sub -> ["sub" .= sub]) (_serviceAccountUser s)+ toT = T.pack . show+++base64Encode :: [J.Pair] -> B8.ByteString+base64Encode = base64 . LB.toStrict . J.encode . J.object++base64 :: ByteArray a => a -> B8.ByteString+base64 = convertToBase Base64URLUnpadded
+ src/Network/Goggles/Auth/GCP/TokenExchange.hs view
@@ -0,0 +1,145 @@+{-# language OverloadedStrings, DeriveGeneric, TypeFamilies, GeneralizedNewtypeDeriving #-}+{-# language FlexibleContexts, MultiParamTypeClasses, DataKinds, FlexibleInstances #-}+module Network.Goggles.Auth.GCP.TokenExchange (+ scopesDefault+ , GCP+ , requestTokenGCP+ , getObject+ , getObjectMetadata+ , putObject+ , listObjects+ ) where++import Data.Monoid ((<>))++import Network.HTTP.Req+import Control.Monad.Catch+import Control.Monad.Reader+import qualified Data.ByteString.Lazy as LB+import qualified Data.Text as T+import qualified Data.Text.Encoding as T (encodeUtf8, decodeUtf8)+import qualified Crypto.Random.Types as CR++import Network.Goggles+import Network.Goggles.Types.GCP+import Network.Goggles.Auth+import Network.Goggles.Auth.GCP.JWT+-- import Network.Utils.HTTP (putLbs, getLbs, urlEncode)+++++-- * The GCP type++data GCP++instance HasCredentials GCP where+ type Credentials GCP = GCPServiceAccount++instance HasToken GCP where+ type Options GCP = GCPTokenOptions -- [T.Text]+ type TokenContent GCP = T.Text+ tokenFetch = requestTokenGCP++instance Show (Token GCP) where+ show (Token tok time) = unwords ["GCP Token :", T.unpack tok, "; expires at :", show time]++-- | We can provide a custom http exception handler rather than throwing exceptions with this instance +instance MonadHttp (WebApiM GCP) where+ handleHttpException = throwM++++++-- * Constants++-- | OAuth2 scopes for the various Google Cloud Platform services.+--+-- Please refer to +--+-- > https://developers.google.com/identity/protocols/googlescopes+-- +-- for the full list+scopesDefault :: [T.Text]+scopesDefault = ["https://www.googleapis.com/auth/cloud-platform"]++uriBase :: Url 'Https+uriBase = https "www.googleapis.com"++++-- * Google Cloud Storage (GCS)++-- | `getObject b p` retrieves the contents of a GCS object (of full path `p`) in bucket `b`+getObject :: T.Text -> T.Text -> WebApiM GCP LbsResponse+getObject b p = do+ tok <- accessToken + let+ opts = oAuth2Bearer (T.encodeUtf8 tok) <>+ altMedia+ uri = uriBase /: "storage" /: "v1" /: "b" /: b /: "o" /: p+ getLbs uri opts++-- | `getObjectMetadata b p` retrieves the metadata of a GCS object (of full path `p`) in bucket `b`+getObjectMetadata :: T.Text -> T.Text -> WebApiM GCP LbsResponse+getObjectMetadata b p = do+ tok <- accessToken + let+ opts = oAuth2Bearer (T.encodeUtf8 tok)+ uri = uriBase /: "storage" /: "v1" /: "b" /: b /: "o" /: p+ getLbs uri opts+++-- GET https://www.googleapis.com/storage/v1/b/bucket/o+-- | `listObjects b` retrieves a list of objects stored in bucket `b`+listObjects :: T.Text -> WebApiM GCP LbsResponse+listObjects b = do+ tok <- accessToken + let+ opts = oAuth2Bearer (T.encodeUtf8 tok)+ uri = uriBase /: "storage" /: "v1" /: "b" /: b /: "o"+ getLbs uri opts++-- | `putObject b p body` uploads a bytestring `body` into a GCS object (of full path `p`) in bucket `b`+putObject :: T.Text -> T.Text -> LB.ByteString -> WebApiM GCP LbsResponse+putObject b objName body = do+ tok <- accessToken+ let+ opts = oAuth2Bearer (T.encodeUtf8 tok) <>+ ulMedia <>+ ("name" =: objName)+ uri = uriBase /: "upload" /: "storage" /: "v1" /: "b" /: b /: "o"+ putLbs uri opts body +++ +-- | Constant request parameters required by GCS calls+altMedia, ulMedia :: Option 'Https+altMedia = "alt" =: ("media" :: T.Text)+ulMedia = "uploadType" =: ("media" :: T.Text)++ ++++-- | Implementation of `tokenFetch`+requestTokenGCP :: WebApiM GCP (Token GCP)+requestTokenGCP = do+ saOk <- asks credentials+ opts <- asks options+ t0 <- requestGcpOAuth2Token saOk opts+ tutc <- mkOAuth2TokenUTC (2 :: Int) t0+ return (Token (oauTokenString tutc) (oauTokenExpiry tutc))+++-- | Request an OAuth2Token+requestGcpOAuth2Token :: (MonadThrow m, CR.MonadRandom m, MonadHttp m) =>+ GCPServiceAccount -> GCPTokenOptions -> m OAuth2Token+requestGcpOAuth2Token serviceAcct opts = do+ jwt <- T.decodeUtf8 <$> encodeBearerJWT serviceAcct opts+ requestOAuth2Token+ (uriBase /: "oauth2" /: "v4" /: "token")+ [("grant_type", T.pack $ urlEncode "urn:ietf:params:oauth:grant-type:jwt-bearer"),+ ("assertion", jwt)]+ (header "Content-Type" "application/x-www-form-urlencoded; charset=utf-8")
+ src/Network/Goggles/GCS.hs view
@@ -0,0 +1,40 @@+{-|+== /Dependencies/++The examples require the following declarations (which in turn mean that the @req@ and @bytestring@ libraries are imported by the user's project). You will also need the @OverloadedStrings@ language extension :++> import qualified Data.ByteString.Lazy as LB+> import Network.HTTP.Req (responseBody)+> import Network.Goggles++== /Examples/++This first example, @listBucket@, reads content from a cloud storage bucket:++1. it loads the GCP credentials (username and RSA key),+2. retrieves a token via OAuth2,+3. performs a single call to the Cloud Storage API endpoint that lists the metadata related to the contents of a storage bucket, and+4. returns the raw API data to the user as a lazy ByteString.++> listBucket :: IO LB.ByteString+> listBucket = do+> let usr = "...iam.gserviceaccount.com"+> bucket = "<my-gcs-bucket>"+> key = "<rsa_key>"+> pvtkey <- parseRSAPrivateKey key+> let creds = GCPServiceAccount pvtkey usr Nothing ""+> hdl <- createHandle creds scopesDefault+> responseBody <$> evalWebApiIO hdl (listObjects bucket)++-}++module Network.Goggles.GCS (+ GCP(..), scopesDefault,+ getObject,+ getObjectMetadata,+ listObjects,+ putObject+ )+where++import Network.Goggles.Auth.GCP
+ src/Network/Goggles/Types/GCP.hs view
@@ -0,0 +1,25 @@+{-# language TypeFamilies, FlexibleInstances #-}+module Network.Goggles.Types.GCP (+ GCPServiceAccount(..)+ , GCPTokenOptions(..)+ ) where++import qualified Data.Text as T+import Crypto.PubKey.RSA.Types+++++-- | Credentials for Google Cloud Platform+data GCPServiceAccount = GCPServiceAccount {+ _servicePrivateKey :: PrivateKey -- ^ Private key (i.e. the PEM string obtained from the Google API Console)+ , _serviceEmail :: T.Text -- ^ Email address of the service account (ending in .gserviceaccount.com )+ , _serviceAccountUser :: Maybe T.Text -- ^ email address of the user for which the application is requesting delegated access (defaults to the service account email if Nothing)+ , _gcpServiceAcctSecret :: T.Text -- ^ Service account secret+ } deriving (Eq, Show)++++data GCPTokenOptions = GCPTokenOptions {+ _tokenOptionsScopes :: [T.Text] -- ^ authentication scopes that the application requests+ } deriving (Eq, Show)