├── .gitignore ├── Setup.hs ├── test └── Spec.hs ├── .dockerignore ├── stack.yaml ├── app └── Main.hs ├── .codeclimate.yml ├── README.md ├── Dockerfile ├── Makefile ├── .stylish-haskell.yaml ├── circle.yml ├── Build.dockerfile ├── LICENSE ├── package.yaml └── src └── Popeye ├── CLI.hs └── Users.hs /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .stack-work/ 3 | build 4 | *.cabal 5 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /test/Spec.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -F -pgmF hspec-discover #-} 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.md 2 | .env 3 | .git 4 | .stack-work 5 | test 6 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-9.12 2 | packages: 3 | - '.' 4 | extra-deps: [] 5 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import Popeye.CLI 4 | 5 | main :: IO () 6 | main = run 7 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | hlint: 3 | enabled: true 4 | ratings: 5 | paths: 6 | - "**.hs" 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Generate and output an `authorized_keys` file based on an IAM group. 2 | 3 | ![iam](http://cdn2.hubspot.net/hub/153377/file-18038864-gif/images/popeye_i_am_what_i_am_t_copy.gif) 4 | 5 | ## Usage 6 | 7 | - Create a group in IAM 8 | - Assign users to said group 9 | - Have users upload public keys to their account 10 | 11 | ``` 12 | docker run \ 13 | --env AWS_ACCESS_KEY_ID=... \ 14 | --env AWS_SECRET_ACCESS_KEY=... \ 15 | codeclimate/popeye --group GROUP 16 | ``` 17 | 18 | ## Installation 19 | 20 | Available on DockerHub 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie 2 | MAINTAINER Pat Brisbin 3 | 4 | WORKDIR /home/app 5 | 6 | RUN apt-get update \ 7 | && apt-get install -y ca-certificates \ 8 | && apt-get clean \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | # We must set a region for the API client to intialize, but what region we set 12 | # does not matter since we only access IAM, which is a cross-region service. 13 | ENV AWS_REGION us-east-1 14 | 15 | COPY build/popeye /home/app/popeye 16 | COPY LICENSE /home/app/LICENSE 17 | 18 | ENTRYPOINT ["/home/app/popeye"] 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE_NAME ?= codeclimate/popeye 2 | 3 | build: 4 | mkdir -p build 5 | 6 | image: 7 | docker build --tag $(IMAGE_NAME)-build --file Build.dockerfile . 8 | 9 | build/popeye: image build 10 | docker run --rm --volume "$(PWD)/build:/build" $(IMAGE_NAME)-build \ 11 | cp /home/app/dist/build/popeye/popeye /build/popeye 12 | 13 | release: build/popeye 14 | docker build --tag $(IMAGE_NAME) . 15 | 16 | check: release 17 | docker run --rm --volume ~/.aws:/root/aws:ro \ 18 | codeclimate/popeye --group ssh --user pat@codeclimate.com 19 | 20 | .PHONY: image 21 | -------------------------------------------------------------------------------- /.stylish-haskell.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - simple_align: 3 | cases: false 4 | top_level_patterns: false 5 | records: false 6 | - imports: 7 | align: none 8 | list_align: after_alias 9 | pad_module_names: false 10 | long_list_align: new_line_multiline 11 | empty_list_align: right_after 12 | list_padding: 4 13 | separate_lists: false 14 | space_surround: false 15 | - language_pragmas: 16 | style: vertical 17 | align: false 18 | remove_redundant: true 19 | - trailing_whitespace: {} 20 | columns: 80 21 | newline: native 22 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | services: 3 | - docker 4 | dependencies: 5 | cache_directories: 6 | - "~/.stack" 7 | pre: 8 | - wget https://github.com/commercialhaskell/stack/releases/download/v0.1.6.0/stack-0.1.6.0-linux-x86_64.tar.gz -O /tmp/stack.tar.gz 9 | - tar xvzOf /tmp/stack.tar.gz stack-0.1.6.0-linux-x86_64/stack > /tmp/stack 10 | - chmod +x /tmp/stack && sudo mv /tmp/stack /usr/bin/stack 11 | override: 12 | - stack setup 13 | - stack build 14 | 15 | test: 16 | override: 17 | - stack test 18 | 19 | deployment: 20 | registry: 21 | branch: master 22 | commands: 23 | - make release 24 | - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS 25 | - docker push codeclimate/popeye 26 | 27 | notify: 28 | webhooks: 29 | - url: https://cc-slack-proxy.herokuapp.com/circle 30 | -------------------------------------------------------------------------------- /Build.dockerfile: -------------------------------------------------------------------------------- 1 | # vim: ft=dockerfile 2 | FROM haskell:8 3 | MAINTAINER Pat Brisbin 4 | 5 | WORKDIR /home/app 6 | 7 | RUN apt-get update \ 8 | && apt-get install -y curl \ 9 | && apt-get clean \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Fix dependencies to LTS 9.12 13 | RUN curl -o /home/app/cabal.config https://www.stackage.org/lts-9.12/cabal.config 14 | 15 | # Pre-install large dependencies in separate layer 16 | RUN cabal update && cabal install \ 17 | amazonka \ 18 | amazonka-iam \ 19 | hpack \ 20 | lens \ 21 | optparse-applicative 22 | 23 | COPY package.yaml /home/app/package.yaml 24 | 25 | RUN hpack 26 | RUN cabal install --dependencies-only 27 | 28 | COPY LICENSE /home/app/LICENSE 29 | COPY src /home/app/src 30 | COPY app /home/app/app 31 | # Run hpack again now that src/app are present, so it can correctly create the 32 | # modules declaration. 33 | RUN hpack 34 | RUN cabal configure -fstatic 35 | RUN cabal build 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Code Climate 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /package.yaml: -------------------------------------------------------------------------------- 1 | name: popeye 2 | version: '0.1.0.0' 3 | synopsis: Generate an authorized_keys file from an IAM group 4 | description: Please see README.md 5 | category: Network 6 | author: Pat Brisbin 7 | maintainer: pbrisbin@gmail.com 8 | copyright: 2017 Code Climate 9 | license: MIT 10 | github: codeclimate/popeye 11 | homepage: http://github.com/codeclimate/popeye#readme 12 | 13 | dependencies: 14 | - base 15 | 16 | library: 17 | source-dirs: src 18 | dependencies: 19 | - amazonka 20 | - amazonka-iam 21 | - lens 22 | - optparse-applicative 23 | - text 24 | - transformers 25 | 26 | executables: 27 | popeye: 28 | main: Main.hs 29 | source-dirs: app 30 | dependencies: 31 | - base 32 | - popeye 33 | 34 | when: 35 | - condition: flag(static) 36 | then: 37 | ghc-options: 38 | - -threaded 39 | - -rtsopts 40 | - -with-rtsopts=-N 41 | - -static 42 | - -optl-static 43 | - -optl-pthread 44 | else: 45 | ghc-options: 46 | - -threaded 47 | - -rtsopts 48 | - -with-rtsopts=-N 49 | 50 | tests: 51 | popeye-test: 52 | main: Spec.hs 53 | source-dirs: test 54 | ghc-options: 55 | - -threaded 56 | - -rtsopts 57 | - -with-rtsopts=-N 58 | dependencies: 59 | - popeye 60 | - hspec 61 | - QuickCheck 62 | 63 | flags: 64 | static: 65 | description: Compile statically 66 | manual: false 67 | default: false 68 | -------------------------------------------------------------------------------- /src/Popeye/CLI.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | module Popeye.CLI (run) where 3 | 4 | import Popeye.Users 5 | 6 | import Control.Lens (set) 7 | import Control.Monad.IO.Class 8 | import Data.Semigroup ((<>)) 9 | import Network.AWS 10 | import Options.Applicative 11 | import System.IO (stderr) 12 | 13 | import qualified Data.Text as T 14 | import qualified Data.Text.IO as T 15 | 16 | data Options = Options 17 | { oGroups :: [Group] 18 | , oUsers :: [UserName] 19 | , oDebug :: Bool 20 | } 21 | 22 | run :: IO () 23 | run = do 24 | opts <- getOptions 25 | lgr <- newLogger (if oDebug opts then Debug else Error) stderr 26 | env <- set envLogger lgr <$> newEnv Discover 27 | 28 | runResourceT . runAWS env $ do 29 | users <- (++) 30 | <$> getUsers (oGroups opts) 31 | <*> mapM getUser (oUsers opts) 32 | 33 | mapM_ outputUser users 34 | 35 | outputUser :: MonadIO m => User -> m () 36 | outputUser u = do 37 | let hdr = "# " <> unUserName (userName u) 38 | keys = map unUserPublicKey $ userPublicKeys u 39 | 40 | liftIO $ T.putStrLn $ T.unlines $ hdr:keys 41 | 42 | getOptions :: IO Options 43 | getOptions = execParser $ info (helper <*> parseOptions) 44 | (fullDesc <> progDesc "Generate authorized_keys from users in an IAM group") 45 | 46 | parseOptions :: Parser Options 47 | parseOptions = Options 48 | <$> many (Group . T.pack <$> strOption 49 | ( short 'g' 50 | <> long "group" 51 | <> metavar "GROUP" 52 | <> help "IAM group to query" 53 | )) 54 | <*> many (UserName . T.pack <$> strOption 55 | ( short 'u' 56 | <> long "user" 57 | <> metavar "USER" 58 | <> help "IAM user name" 59 | )) 60 | <*> switch 61 | ( short 'd' 62 | <> long "debug" 63 | <> help "Show debug information" 64 | ) 65 | -------------------------------------------------------------------------------- /src/Popeye/Users.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | module Popeye.Users 3 | ( User(..) 4 | , UserName(..) 5 | , UserPublicKey(..) 6 | , Group(..) 7 | , getUsers 8 | , getUser 9 | ) where 10 | 11 | import Control.Lens (set, view) 12 | import Control.Monad (mfilter) 13 | import Data.Maybe (catMaybes) 14 | import Data.Text (Text) 15 | import Network.AWS 16 | import Network.AWS.IAM.GetGroup 17 | import Network.AWS.IAM.GetSSHPublicKey 18 | import Network.AWS.IAM.ListSSHPublicKeys 19 | import Network.AWS.IAM.Types hiding (User, Group) 20 | 21 | newtype Group = Group { unGroup :: Text } 22 | newtype PublicKeyId = PublicKeyId { unPublicKeyId :: Text } 23 | 24 | newtype UserName = UserName { unUserName :: Text } 25 | newtype UserPublicKey = UserPublicKey { unUserPublicKey :: Text } 26 | 27 | data User = User 28 | { userName :: UserName 29 | , userPublicKeys :: [UserPublicKey] 30 | } 31 | 32 | getUsers :: [Group] -> AWS [User] 33 | getUsers gs = mapM getUser =<< concat <$> mapM getUserNames gs 34 | 35 | getUser :: UserName -> AWS User 36 | getUser un = do 37 | pks <- mapM (getPublicKeyContent un) =<< getPublicKeyIds un 38 | 39 | return User 40 | { userName = un 41 | , userPublicKeys = catMaybes pks 42 | } 43 | 44 | getUserNames :: Group -> AWS [UserName] 45 | getUserNames g = do 46 | rsp <- send $ getGroup $ unGroup g 47 | return $ (UserName . view uUserName) <$> view ggrsUsers rsp 48 | 49 | getPublicKeyIds :: UserName -> AWS [PublicKeyId] 50 | getPublicKeyIds un = do 51 | rsp <- send $ set lspkUserName (Just $ unUserName un) listSSHPublicKeys 52 | return $ (PublicKeyId . view spkmSSHPublicKeyId) <$> view lspkrsSSHPublicKeys rsp 53 | 54 | getPublicKeyContent :: UserName -> PublicKeyId -> AWS (Maybe UserPublicKey) 55 | getPublicKeyContent un pk = do 56 | rsp <- send $ getSSHPublicKey (unUserName un) (unPublicKeyId pk) SSH 57 | return $ (UserPublicKey . view spkSSHPublicKeyBody) 58 | <$> (mfilter ((== Active) . view spkStatus) $ view gspkrsSSHPublicKey rsp) 59 | --------------------------------------------------------------------------------