├── .gitignore ├── .zappr.yaml ├── DATA_CLASSIFICATION.md ├── Dockerfile ├── LICENSE ├── MAINTAINERS ├── README.rst ├── coveralls.sh ├── delivery.yaml ├── dev-resources ├── dockerfile.sshd ├── entrypoint.sh ├── key1.pem ├── key1.pem.pub ├── key2.pem └── key2.pem.pub ├── dev └── user.clj ├── example-senza-definition.yaml ├── grant-ssh-access-forced-command.py ├── project.clj ├── resources ├── api │ └── even-api.yaml ├── db │ ├── even.sql │ └── migration │ │ └── V1__Basic_schema.sql └── log4j2.xml ├── src └── org │ └── zalando │ └── stups │ └── even │ ├── api.clj │ ├── audit.clj │ ├── core.clj │ ├── job.clj │ ├── pubkey_provider │ └── usersvc.clj │ ├── sql.clj │ └── ssh.clj ├── test └── org │ └── zalando │ └── stups │ └── even │ ├── api_test.clj │ ├── audit_test.clj │ ├── core_test.clj │ ├── job_test.clj │ ├── sql_test.clj │ └── ssh_test.clj └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | public-keys/ 3 | target/ 4 | .lein* 5 | profiles.clj 6 | .idea 7 | *.iml 8 | local-* 9 | .nrepl* 10 | scm-source.json 11 | .testcontainers* 12 | -------------------------------------------------------------------------------- /.zappr.yaml: -------------------------------------------------------------------------------- 1 | approvals: 2 | groups: 3 | zalando: 4 | minimum: 2 5 | from: 6 | orgs: 7 | - "zalando" 8 | X-Zalando-Team: "teapot" 9 | -------------------------------------------------------------------------------- /DATA_CLASSIFICATION.md: -------------------------------------------------------------------------------- 1 | | Data Type | Purpose | 2 | |-----------| ------- | 3 | | User UID | Used for authentication and authorization. | 4 | | IP addresses | Used for auditing, monitoring and troubleshooting (logged for every request to the service). | 5 | | Email | Part of the users public ssh key which is handed out by the application. | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry.opensource.zalan.do/library/openjdk-8:latest 2 | label maintainer="Zalando SE" 3 | 4 | # add AWS RDS CA bundle 5 | RUN mkdir /tmp/rds-ca && \ 6 | curl https://s3.amazonaws.com/rds-downloads/rds-combined-ca-bundle.pem > /tmp/rds-ca/aws-rds-ca-bundle.pem 7 | # split the bundle into individual certs (prefixed with xx) 8 | # see http://blog.swwomm.com/2015/02/importing-new-rds-ca-certificate-into.html 9 | RUN cd /tmp/rds-ca && csplit -sz aws-rds-ca-bundle.pem '/-BEGIN CERTIFICATE-/' '{*}' 10 | RUN for CERT in /tmp/rds-ca/xx*; do mv $CERT /usr/local/share/ca-certificates/aws-rds-ca-$(basename $CERT).crt; done 11 | 12 | RUN update-ca-certificates 13 | 14 | COPY target/even.jar / 15 | COPY resources/api/even-api.yaml /zalando-apis/ 16 | 17 | EXPOSE 8080 18 | 19 | VOLUME /tmp 20 | 21 | ENTRYPOINT ["java", \ 22 | "-XX:InitialRAMPercentage=80.0", \ 23 | "-XX:MinRAMPercentage=80.0", \ 24 | "-XX:MaxRAMPercentage=80.0", \ 25 | "-XX:+ExitOnOutOfMemoryError", \ 26 | "-jar", \ 27 | "even.jar"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Zalando SE 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Henning Jacobs 2 | Team Teapot 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Even - SSH Access Granting Service 3 | ================================== 4 | 5 | SSH access granting service to distribute personal public SSH keys on demand. 6 | 7 | 8 | Idea 9 | ==== 10 | 11 | Users can request temporary SSH access to servers by calling the "SSH Access Granting Service" which puts their public SSH key in place. 12 | 13 | * The user needs to authenticate against the service (via OAuth2 access token) 14 | * The user requests temporary SSH access for a certain host (``POST /access-requests``) 15 | * The service checks whether the user is allowed to gain access to the specified host by checking if the hostname follows the given pattern (``HTTP_ALLOWED_HOSTNAME_TEMPLATE``) 16 | * The service instructs the host to grant access via a SSH forced command script 17 | * The forced command script downloads the user's public SSH key from the service (the public SSH key is read from the HTTP endpoint given by ``USERSVC_SSH_PUBLIC_KEY_URL_TEMPLATE``) 18 | * The forced command script configures the ``/home//.ssh/authorized_keys`` file accordingly 19 | 20 | .. image:: http://docs.stups.io/en/latest/_images/grant-ssh-access-flow.svg 21 | :alt: Grant SSH access flow 22 | 23 | Development 24 | =========== 25 | 26 | The service is written in Clojure. You need Leiningen_ installed to build or develop. 27 | 28 | To start a web server for the application, run: 29 | 30 | .. code-block:: bash 31 | 32 | $ export CREDENTIALS_DIR=. # to make "tokens" library happy 33 | $ export OAUTH2_ACCESS_TOKENS=user-service=abc123-456 # fixed token for local development 34 | $ export .. # see configuration section below 35 | $ lein repl 36 | => (go) 37 | 38 | The service is now exposing its HTTP REST API under http://localhost:8080/. 39 | 40 | Requesting access to server "127.0.0.1" for user "jdoe": 41 | 42 | .. code-block:: bash 43 | 44 | $ curl -XPOST -H Content-Type:application/json -H 'Authorization: Bearer mytoken' --data '{"hostname": "127.0.0.1", "reason": "test"}' http://localhost:8080/access-requests 45 | 46 | Building 47 | ======== 48 | 49 | To build a deployable artifact, use the ``uberjar`` task, that produces a single JAR file, that every JVM should be able to execute. 50 | 51 | .. code-block:: bash 52 | 53 | $ lein do uberjar, scm-source, docker build 54 | 55 | Running 56 | ======= 57 | 58 | Running the previously built Docker image and passing configuration via environment variables: 59 | 60 | .. code-block:: bash 61 | 62 | $ docker run -p 8080:8080 -e AWS_REGION_ID=eu-west-1 -e CREDENTIALS_DIR=/ -e HTTP_TEAM_SERVICE_URL=https://teams.example.org -e HTTP_TOKENINFO_URL=https://oauth2.example.org/tokeninfo -e HTTP_ALLOWED_HOSTNAME_TEMPLATE="odd-[a-z0-9-]*.{team}.example.org" -e OAUTH2_ACCESS_TOKEN_URL=https://oauth2.example.org/access_token -e USERSVC_SSH_PUBLIC_KEY_URL_TEMPLATE=https://users.example.org/{user}/ssh -e SSH_PRIVATE_KEY="$SSH_PRIVATE_KEY" stups/even 63 | 64 | All configuration values can be passed encrypted when running on AWS (this is supported by the underlying Friboo_ library): 65 | 66 | .. code-block:: bash 67 | 68 | $ aws kms encrypt --key-id 123 --plaintext "secret" # encrypt with KMS 69 | $ export SSH_PRIVATE_KEY="aws:kms:" 70 | 71 | Configuration 72 | ============= 73 | 74 | The following configuration parameters can/should be passed via environment variables: 75 | 76 | ``AWS_REGION_ID`` 77 | Optional AWS region ID to use for KMS decryption (e.g. "eu-west-1"). 78 | ``CREDENTIALS_DIR`` 79 | Folder with OAuth2 application credentials (user.json and client.json) --- this is automatically set when running on the Taupage AMI. 80 | ``HTTP_TEAM_SERVICE_URL`` 81 | URL of the Team Service to check team membership for authorization. 82 | ``HTTP_TOKENINFO_URL`` 83 | URL to OAuth2 token info endpoint. 84 | ``HTTP_ALLOWED_HOSTNAME_TEMPLATE`` 85 | Regex template for the allowed hostname. "{team}" will be replaced by the user's team ID. Example: "odd-[a-z0-9-]*.{team}.example.org" 86 | ``OAUTH2_ACCESS_TOKEN_URL`` 87 | URL to OAuth2 provider endpoint to get a new service access token. 88 | ``SSH_AGENT_FORWARDING`` 89 | Boolean flag whether to use agent forwarding (``-A``). Agent forwarding is necessary for bastion host support. 90 | ``SSH_PORT`` 91 | SSH port number to use (default: 22). 92 | ``SSH_PRIVATE_KEYS`` 93 | The SSH private keys in PEM format. Can be encrypted, since KMS doesn't support data larger than 4k. 94 | ``SSH_PRIVATE_KEY_PASSWORD`` 95 | Password for the SSH keys, optional. 96 | ``SSH_USER`` 97 | The SSH username on remote servers (default: "granting-service"). 98 | ``USERSVC_CACHE_BUCKET`` 99 | Optional S3 bucket name to use for caching SSH public keys (to bridge potential downtimes of upstream HTTP service). 100 | ``USERSVC_SSH_PUBLIC_KEY_URL_TEMPLATE`` 101 | URL template for the public SSH key endpoints ("{user}" will be replaced with the user's ID/username). Example: "https://users.example.org/employees/{user}/ssh" 102 | 103 | Requesting SSH Access 104 | ===================== 105 | 106 | Users can use the convenience script Piu_ instead of doing a manual HTTP POST. 107 | 108 | .. code-block:: bash 109 | 110 | $ sudo pip3 install --upgrade stups-piu 111 | $ piu 172.31.0.1 "testing the piu script" 112 | 113 | 114 | .. _Leiningen: http://leiningen.org/ 115 | .. _Friboo: https://github.com/zalando-stups/friboo 116 | .. _Piu: http://stups.readthedocs.org/en/latest/components/piu.html 117 | 118 | License 119 | ======= 120 | 121 | Copyright © 2015 Zalando SE 122 | 123 | Licensed under the Apache License, Version 2.0 (the "License"); 124 | you may not use this file except in compliance with the License. 125 | You may obtain a copy of the License at 126 | 127 | http://www.apache.org/licenses/LICENSE-2.0 128 | 129 | Unless required by applicable law or agreed to in writing, software 130 | distributed under the License is distributed on an "AS IS" BASIS, 131 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 132 | See the License for the specific language governing permissions and 133 | limitations under the License. 134 | -------------------------------------------------------------------------------- /coveralls.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | COVERALLS_URL='https://coveralls.io/api/v1/jobs' 4 | CLOVERAGE_VERSION='1.0.4' lein2 cloverage -o cov --coveralls 5 | curl -F 'json_file=@cov/coveralls.json' "$COVERALLS_URL" 6 | -------------------------------------------------------------------------------- /delivery.yaml: -------------------------------------------------------------------------------- 1 | version: "2017-09-20" 2 | 3 | pipeline: 4 | - id: build 5 | type: script 6 | overlay: ci/clojure 7 | env: 8 | LEIN_ROOT: true 9 | commands: 10 | - desc: Build and test 11 | cmd: | 12 | lein do clean, test, uberjar 13 | - desc: Build docker image 14 | cmd: | 15 | IMAGE="pierone.stups.zalan.do/teapot/even:cdp${CDP_TARGET_REPOSITORY_COUNTER}" 16 | docker build -t "$IMAGE" . 17 | if [[ "${CDP_TARGET_BRANCH}" = "master" && -z "${CDP_PULL_REQUEST_NUMBER}" ]]; then 18 | docker push "$IMAGE" 19 | fi 20 | -------------------------------------------------------------------------------- /dev-resources/dockerfile.sshd: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | ADD entrypoint.sh /entrypoint.sh 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y openssh-server && \ 7 | mkdir /root/.ssh && \ 8 | chmod 700 /root/.ssh && \ 9 | mkdir /var/run/sshd 10 | EXPOSE 22 11 | ENTRYPOINT ["/entrypoint.sh"] 12 | -------------------------------------------------------------------------------- /dev-resources/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | 3 | cp /authorized_keys /root/.ssh/authorized_keys 4 | chmod 600 /root/.ssh/authorized_keys 5 | chown root:root /root/.ssh/authorized_keys 6 | 7 | exec "/usr/sbin/sshd" "-D" "-e" "-p" "22" "-o" "PasswordAuthentication=no" 8 | -------------------------------------------------------------------------------- /dev-resources/key1.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: AES-128-CBC,BBF7CF58DE283332C2FEDCE40004B1A2 4 | 5 | oSdEbcBmjlxvA9g/kEg8biur9EIMlbyqEwTQvo1XXYIjimUBMgJNZ143VwH9U09l 6 | osfBLQQG2fzjezgRVDxQVREyJNagSnwicUtPPfE51BCCQX4YhVTnhaSQwMMSQS7n 7 | TcGH4Ds6/2RGneZ1Fql/jiqDXKHUp2/FkQZANOIcZfM97z8/9+oBZOUC9o4/rytV 8 | P5g0Eg3Z6DNJqV3jLygXH4sM/7Ehu1niaxn+8vgjdaZyvUdHQWrFNapVpJ7+9ftX 9 | VfXGFkklmxn6mGpZ1jukDANjt0h+XYPDHSqwaOmIEExxC1Aj7OnEII55gGRRGn7Y 10 | tZ4Fp1adwkejU0ZMUeIynluHADC0O7bkPV2JvuqloyPlx3X2/lXj9bQe15lTp0Ze 11 | uziv2F+kn3+6TWB5cTII0SfGeCjud1k7LACSlNT48Ffu6ZeoOorqqunozEQEtkr9 12 | 3AgwLdPCD4qdGsDvaTsWswodAxYVVP7pchkvQu8fcua1y9eRPMotxGjGvyc4RYAU 13 | i8nPwgBr+PuzvyyugTDrNjTILbq5pcLLdlV7fezkv+5wV0MR7dZNq7WbH2U4pfdT 14 | BlhIxD8omlVCJtWHNTvPdJh+tna4R7UdZgKr+/yT7VF2GT6SuIhsyraksARzjJJu 15 | NIfCrkcO1ITyF+lRpXWO8RgVDk3fkNL9j2WgEUjNoh/jDA42g19TTXWdTTIMcloV 16 | RPCl5ci188k62G6oXjU0JqXgK9jYQvG48cWSuoR+sEXSV8pDaQUMHeIeeH/e3Qkz 17 | 8XojSSM7m7pVrlWA2suaOWu3GT1twcT7oc62jrim2ZWZ51snmBvahbqWfTSPh8Wm 18 | pFG1KJJxdSrGn33FgOkQLHKxtjfbgyUaHVQUjasBYnJXPQfuxt2MtpG5uuz9TSwa 19 | XlXESJvgt6SpMlAzlw8Nv7G9SMG7Dt9vAIXYRgA5BSVvWXupapoS30pLddcHt2ye 20 | qxDxDcYHyQsNSb3cAoGMDwBaa8NHPqRlU/lOt9wXtd4Fc0dZesxEDeooZPinWKAH 21 | wcvLp95l6mgF92/DJ8FWFoFy3w+Yc8uIkVeEC+AFEAQ/dDQCcV2MY8iDAIFuavZP 22 | 6lpDRjtJdE/zmBmDZN0oz0w2wqAInASUddNh1cMbVrH5FOeCWydojkxfoJpBgUzx 23 | DRKgWu62E9kOGKLTb7lmgBUv9QN0zxmCb3vaMkMa9Oz/w4tWyjSsbEmeyk4xRVyh 24 | 6BRh1Ba8DYWgqUARRHc/W7LvfVrKmsSZI1ugHP93uyFMn8UCCafgQrFT/23/zZ8E 25 | oWQzfgQe3wnc77T303zesYz93ybft2T/QHcx+3aGNUpfrdzUUKrmZXgq2j6IQpav 26 | sNVFaeCzHNGpcUCmJW7eC/JA07r0hK3ICpEPBpG/mb8di8faWhDo1dHl599OA4+W 27 | a1dm2bjEVtygD7mtyE0nJlgwcwtsIEhLQkDvu3rb0Ad3MEzar6pMrN5gSvi9Tr9u 28 | faj4UdjSHXMoWmFaSNJa89DIyN/zO5+mjaxOMzQbfUKNeQF7Sah0X80uffgGVNqx 29 | IRmCp8PWNG3CBjKlv9olzo9tuhLDUBXvAPXLE/69H2eRrlrXMz5SRJOdZDSgaD3Q 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /dev-resources/key1.pem.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQD2WgwhaialefJuN0PHaQRsuUL8bTnkBtQ7A8cwHXFAkoV+bgbkXH1bgYhW8kP/dxvfaREv0pVl2tMHbo/tRz0t2MBJE9nC7C71pX+qnIivlS6PvPlOYx431hO6WixbNBEYnTSV46TrswKOitAKhNgZ8lEbkpSptZU3EFVRVq2fh899dHPut0rgDxBSfusVAbQOP5jIhyaVU7CdHnbLTarVNGqN3hjTU6uciL7SdvPzwb1qeffwAEzcWc45aJebBD4wcmnorVhyeBJLrFrQj5lp6sM0LCxscjxQjmnZK8aKeJf+z2cuemB8Zv4qyIYmEX+C9XDdPcFAEFvqShjTn1x9 aermakov@ZALANDO-25931 2 | -------------------------------------------------------------------------------- /dev-resources/key2.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: AES-128-CBC,93251EBA292A409DCBE7836D288956D3 4 | 5 | /gVM48GyM+dsjQTrGn45t7eHLnhh/Csy/kcHNxGg8sYplbWHfXEYZyExqNOjqoMi 6 | AZgzc3ZEUbUPQxYj8il9S8SmLrBgcK8zZxta68jWtWTmMHi9RExfWPTxA29xFpc6 7 | 31Pf/2MWNIxadkr69Pt7f0kK1xHf9pun9UpWiH/f0dQ7YX26SHdRcTOhUciqNQVW 8 | UkIa99V0dN2M2GwDvWCUr34Eek8fnwKdRlfmGSnY+PCtC6twu3aEuvRQIA/u0L2F 9 | GXwWw8sMiiIt8dY+cIZV6rPfQnJXzbCLjqMjrqA1CAf5+N8h7RcfFo277cMqAHHm 10 | GJqYq/vdcK48hJmPHduGI9bxh+hQeGAnlDnYDHa5eQ+daHNTxyaPoHalqOHZx7ax 11 | 2JkPUYgSQlq9YqKtVU5KTeKNRUOpi3Llxp1wSzd5gjAn+wEjRDhXrxDa//tReVgB 12 | d2MKR6+qUn9yUBRi6ZccDLEkVfg9QaEwSMjOa79Yaed3OFbo30XeHPXIhqIyQ8bo 13 | /R9cDbrnWJjBxASP4FmAsRHa4CUSp/suozyHXz4IhacbJ5ktDmi21KEewW38W3wv 14 | J0zEEexUqgFwcALstr5xi77kiyoVGpm6PJ64kSMKJoypikX+QI0x+Cj4iGTYFXNI 15 | iXA6Ye/m+zVta/NwU6BssZSEWHEtWkEO6kndHyh4lTheNNhZibmeYJQzJ/JzI++s 16 | nwI/4SaMBXYgjd9sO/S8EAxHW3viJ1/leviSWKHe3QR5WC09FgjUi8dcFn6edI2e 17 | gBUc1xFRSqko+8yneP140jHfeQTC1yI7PsTo9PUwW6TghRcLlZ0QOdvpnW88pMbY 18 | iTKsgRUDauFh7FEtXbbLAffimfSfedNU4plrRFGs6ZNh6AlDAFOJ6msMdQCuZlzD 19 | gwoJwyORd1TwcfyhMW9phZfLCBz8QFMZoutvmXeR45lrzx/ar1uJbUi8gCWuqsL3 20 | aZh+vDkJRLOzsSqdyebn3XnCKUBtxCw+gm0avHFvm4oVqPVYACRxgAL8B3P9rOSU 21 | 0Mq2JhqMYYKlbyQe7SpE3zQrSqp1skgNgbyzG590AEJnTgmNlRxFTO6oi1XVKcXm 22 | uQj/ab8PRKUNjwV6A3FlPAy+RrmrulLdtKTOU6TyX65VW07kqF+lpjjOrEkAe95F 23 | 5e0XcopDf6GlLh0vHAasv+3EE0LyVJ55OhbxUoKUGrTEQxLz5JyRvbQ0fbnPStO8 24 | VWgMBnsgtBY5EhVAVBROBDStNrbPlkh8os52T+jjyGSdSThuppyYS0nj7pJ+Vwlx 25 | 1XS0CNWcuAV1CKjZRaQndu2RPu1h7MpJJyAqyIgmib+ijkv1A4d6WKE0Ww40pdhc 26 | wv6NKW5Ai3OiprYE44KpHA3eC0ZQJDEF2mJCu/Hb0upbbQJiJr65PLeOmf+9FduX 27 | AjGsl1h/T+CPX0VPcQesLQRg7Y5gXn8/zq2mqmyn6c2DIqj0r5n8Soid2YSLd5jh 28 | bXdsxYfZyOncvURkyZdm2+vLW37sQRHcHvgQonucIKYMNgKuPQYx/GtPTytA2sPn 29 | JooRBhnF4/2Hz0Xf2onbo94UCka0owU2QMbmqgx+wtln1n8OULODHP2BNodKKcya 30 | -----END RSA PRIVATE KEY----- 31 | -------------------------------------------------------------------------------- /dev-resources/key2.pem.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDu3hTdeMSOyp0vO9/wqmHLgBQbNyvGdR1vpR1PihmLgxXTzOqWhqJAowYKdn19CaosdUaN2tQ/FH54d4R1dGl2DF8CG+83EOB31uRO72FloPGJ0bTAucnDpoc6J8ngT614f9N1uv6heSwkZH9GlTxpeq2Aj4goanfXDVEOyjjzZj5K3WBFmcpt5mn3H99OR6DsAUlOmRd4159ThT5KpAvxEBSFfZZNbA0D1Kk1HMpZZJPGniqHXfA12MUYZkAyLC0+BeJz7J2jOL+yrBpUPRSZBZmaPsCLiJU7rWTDj9yhU78fp2eWNDz7kKWw3GaGCrsvFpxrsd6ZyYKoKff3sEbX aermakov@ZALANDO-25931 2 | -------------------------------------------------------------------------------- /dev/user.clj: -------------------------------------------------------------------------------- 1 | ; Copyright 2015 Zalando SE 2 | ; 3 | ; Licensed under the Apache License, Version 2.0 (the "License") 4 | ; you may not use this file except in compliance with the License. 5 | ; You may obtain a copy of the License at 6 | ; 7 | ; http://www.apache.org/licenses/LICENSE-2.0 8 | ; 9 | ; Unless required by applicable law or agreed to in writing, software 10 | ; distributed under the License is distributed on an "AS IS" BASIS, 11 | ; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | ; See the License for the specific language governing permissions and 13 | ; limitations under the License. 14 | 15 | ; Copyright 2013 Stuart Sierra 16 | ; 17 | ; The use and distribution terms for this software are covered by the Eclipse Public License 1.0. 18 | ; http://opensource.org/licenses/eclipse-1.0.php 19 | 20 | (ns user 21 | "Tools for interactive development with the REPL. This file should 22 | not be included in a production build of the application." 23 | (:require 24 | [environ.core :refer [env]] 25 | [clojure.java.javadoc :refer [javadoc]] 26 | [clojure.pprint :refer [pprint]] 27 | [clojure.reflect :refer [reflect]] 28 | [clojure.repl :refer [apropos dir doc find-doc pst source]] 29 | [clojure.tools.namespace.repl :refer [refresh refresh-all]] 30 | [com.stuartsierra.component :as component] 31 | [org.zalando.stups.even.core])) 32 | 33 | (def system 34 | "A Var containing an object representing the application under 35 | development." 36 | nil) 37 | 38 | (defn start 39 | "Starts the system running, sets the Var #'system." 40 | [] 41 | (alter-var-root #'system 42 | (constantly (org.zalando.stups.even.core/run (assoc env :system-log-level "DEBUG"))))) 43 | 44 | (defn stop 45 | "Stops the system if it is currently running, updates the Var 46 | #'system." 47 | [] 48 | (alter-var-root #'system 49 | (fn [s] (when s (component/stop s))))) 50 | 51 | (defn go 52 | "Initializes and starts the system running." 53 | [] 54 | (start) 55 | :ready) 56 | 57 | (defn reset 58 | "Stops the system, reloads modified source files, and restarts it." 59 | [] 60 | (stop) 61 | (refresh :after 'user/go)) 62 | -------------------------------------------------------------------------------- /example-senza-definition.yaml: -------------------------------------------------------------------------------- 1 | # Example Senza definition for even SSH access granting service 2 | # see http://docs.stups.io/en/latest/installation/service-deployments.html 3 | SenzaInfo: 4 | StackName: even 5 | # optional SNS topic to send notification mails to 6 | OperatorTopicId: stups-ops 7 | Parameters: 8 | - ImageVersion: 9 | Description: Docker image version of even. 10 | SenzaComponents: 11 | 12 | - Configuration: 13 | Type: Senza::StupsAutoConfiguration 14 | 15 | - AppServer: 16 | Type: Senza::TaupageAutoScalingGroup 17 | ElasticLoadBalancer: AppLoadBalancer 18 | InstanceType: t2.micro 19 | SecurityGroups: 20 | - app-even 21 | # optional additional security group for Turbine to connect to port 7979 22 | - hystrix-streams 23 | IamRoles: [app-even] 24 | TaupageConfig: 25 | application_version: "{{Arguments.ImageVersion}}" 26 | ports: 27 | 8080: 8080 28 | 7979: 7979 29 | runtime: Docker 30 | source: pierone.stups.example.org/stups/even:{{Arguments.ImageVersion}} 31 | # mint bucket for OAuth credential distribution 32 | mint_bucket: exampleorg-stups-mint-123456789123-eu-west-1 33 | environment: 34 | HTTP_ALLOWED_HOSTNAME_TEMPLATE: "odd-[a-z0-9-]*.{team}.example.org" 35 | # OAuth2 tokeninfo endpoint 36 | HTTP_TOKENINFO_URL: "https://auth.example.org/oauth2/tokeninfo" 37 | # Team Service endpoint to check user team membership 38 | HTTP_TEAM_SERVICE_URL: "https://teams.example.org" 39 | # optional S3 bucket name to log change operations to 40 | HTTP_AUDIT_LOGS_BUCKET: "exampleorg-stups-audit-logs-eu-west-1" 41 | OAUTH2_ACCESS_TOKEN_URL: "https://auth.exampleorg.com/oauth2/access_token?realm=services" 42 | PGSSLMODE: verify-full 43 | DB_SUBNAME: "//even.abc123.eu-west-1.rds.amazonaws.com:5432/even?ssl=true" 44 | DB_USER: postgres 45 | DB_PASSWORD: mypass 46 | SSH_PRIVATE_KEY: "aws:kms:.." 47 | USERSVC_SSH_PUBLIC_KEY_URL_TEMPLATE: "https://users.example.org/employees/{user}/ssh" 48 | # optional Scalyr account key to stream application logs to 49 | scalyr_account_key: abc123xyz 50 | # optional etcd DNS domain for Hystrix/Turbine 51 | etcd_discovery_domain: main.stups.example.org 52 | # optional special entry for Turbine discovery 53 | hystrix: ":7979/hystrix.stream" 54 | AutoScaling: 55 | Minimum: 2 56 | Maximum: 8 57 | MetricType: CPU 58 | ScaleUpThreshold: 70 59 | ScaleDownThreshold: 40 60 | - AppLoadBalancer: 61 | Type: Senza::WeightedDnsElasticLoadBalancer 62 | HTTPPort: 8080 63 | HealthCheckPath: /.well-known/health 64 | SecurityGroups: [app-even-lb] 65 | -------------------------------------------------------------------------------- /grant-ssh-access-forced-command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ''' 3 | Grant SSH access for a given user by fetching his public key from the server. 4 | 5 | This script should be used as SSH forced command. 6 | 7 | Testing this script with a local mock service: 8 | 9 | .. code-block:: bash 10 | 11 | $ sudo touch /etc/ssh-access-granting-service.yaml 12 | $ sudo chown $USER /etc/ssh-access-granting-service.yaml 13 | $ echo 'ssh_access_granting_service_url: "http://localhost:9000"' > /etc/ssh-access-granting-service.yaml 14 | $ # serve your own public key via HTTP 15 | $ mkdir -p public-keys/testuser 16 | $ cp ~/.ssh/id_rsa.pub public-keys/testuser/sshkey.pub 17 | $ python3 -m http.server 9000 & 18 | $ ./grant-ssh-access-forced-command.py grant-ssh-access testuser 19 | $ ssh testuser@localhost # try logging in 20 | ''' 21 | 22 | import argparse 23 | import datetime 24 | import ipaddress 25 | import os 26 | import pwd 27 | import re 28 | import requests 29 | import shlex 30 | import socket 31 | import subprocess 32 | import sys 33 | import syslog 34 | import tempfile 35 | import yaml 36 | import time 37 | 38 | from pathlib import Path 39 | 40 | 41 | USER_NAME_PATTERN = re.compile('^[a-z][a-z0-9-]{0,31}$') 42 | HOST_NAME_PATTERN = re.compile('^[a-z0-9.-]{0,255}$') 43 | 44 | CONFIG_FILE_PATH = Path('/etc/ssh-access-granting-service.yaml') 45 | 46 | MARKER = '(generated by even)' 47 | 48 | WELCOME_MESSAGE = 'Your SSH access was granted by even (SSH access granting service) on {date}' 49 | REVOKED_MESSAGE = 'Your SSH access was revoked by even (SSH access granting service) on {date}' 50 | USER_COMMENT = 'SSH user created by even (SSH access granting service) on {date}' 51 | 52 | DEFAULT_SHELL = '/bin/bash' 53 | 54 | 55 | def date(): 56 | now = datetime.datetime.now() 57 | return now.strftime('%Y-%m-%d %H:%M:%S') 58 | 59 | 60 | def fix_ssh_pubkey(user: str, pubkey: str): 61 | '''Validate that the given public SSH key looks like a valid OpenSSH key which can be used in authorized_keys''' 62 | 63 | pubkey = pubkey.strip() 64 | parts = pubkey.split()[:2] # just the type and the key, the "mail" is probably wrong 65 | if not parts: 66 | raise ValueError('Invalid SSH public key... the key is empty') 67 | if not (parts[0].startswith('ssh-') or parts[0].startswith('ecdsa-')): 68 | raise ValueError('Invalid SSH public key... no "rsa", "dsa" or "ecdsa" key...') 69 | 70 | # TODO? check if it can be base64 decoded? 71 | if len(parts[1]) % 4: 72 | raise ValueError('Invalid SSH public key... length modulo 4 is not 0') 73 | 74 | pubkey = ' '.join(parts) 75 | if pubkey.find('@') != -1: 76 | raise ValueError('Invalid SSH public key... no space between key and mail address') 77 | 78 | # add user name as comment 79 | pubkey += ' %s' % user 80 | return pubkey 81 | 82 | 83 | def get_config(): 84 | if CONFIG_FILE_PATH.exists(): 85 | with CONFIG_FILE_PATH.open('rb') as fd: 86 | config = yaml.safe_load(fd) 87 | else: 88 | config = yaml.safe_load(subprocess.check_output(['sudo', 'cat', '/var/lib/cloud/instance/user-data.txt'])) 89 | return config 90 | 91 | 92 | def get_service_url(): 93 | '''Get the service URL from the global config file or from cloud config YAML''' 94 | 95 | config = get_config() 96 | 97 | url = config['ssh_access_granting_service_url'].rstrip('/') 98 | return url 99 | 100 | 101 | def download_public_key(url, name): 102 | '''Download the SSH public key for the given user name from URL''' 103 | 104 | r = requests.get('{url}/public-keys/{name}/sshkey.pub'.format(url=url, name=name), timeout=10) 105 | if r.status_code != 200: 106 | raise Exception('Failed to download public key for "{}" from {}: server returned status {}'.format( 107 | name, url, r.status_code)) 108 | pubkey = fix_ssh_pubkey(name, r.text) 109 | return pubkey 110 | 111 | 112 | def add_our_mark(pubkey): 113 | return '{} {}'.format(pubkey, MARKER) 114 | 115 | 116 | def add_forced_command(pubkey, forced_command): 117 | if forced_command: 118 | return 'command="{}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {}'.format(forced_command, pubkey) 119 | else: 120 | return pubkey 121 | 122 | 123 | def user_exists(user_name: str) -> bool: 124 | try: 125 | pwd.getpwnam(user_name) 126 | return True 127 | except: 128 | return False 129 | 130 | 131 | def get_keys_file_path(user_name: str) -> Path: 132 | pw_entry = pwd.getpwnam(user_name) 133 | 134 | ssh_dir = Path(pw_entry.pw_dir) / '.ssh' 135 | keys_file = ssh_dir / 'authorized_keys' 136 | return keys_file 137 | 138 | 139 | def generate_authorized_keys(user_name: str, keys_file: Path, pubkey: str, forced_command: str=None): 140 | ssh_dir = keys_file.parent 141 | subprocess.check_call(['sudo', 'mkdir', '-p', str(ssh_dir)]) 142 | subprocess.check_call(['sudo', 'chown', user_name, str(ssh_dir)]) 143 | subprocess.check_call(['sudo', 'chmod', '0700', str(ssh_dir)]) 144 | 145 | # NOTE: we write the temporary SSH public key into tmpfs (shm) to also work in "disk full" situations 146 | with tempfile.NamedTemporaryFile(suffix='{name}-sshkey.pub'.format(name=user_name), dir='/dev/shm') as fd: 147 | fd.write(add_our_mark(add_forced_command(pubkey, forced_command)).encode('utf-8')) 148 | fd.flush() 149 | shell_template = 'cat {temp} > {keys_file} && chown {name} {keys_file} && chmod 600 {keys_file}' 150 | subprocess.check_call(['sudo', 'sh', '-c', 151 | shell_template.format(temp=fd.name, name=user_name, keys_file=keys_file)]) 152 | 153 | 154 | def write_welcome_message(home_dir: Path): 155 | '''Write SSH welcome banner to ~/.profile''' 156 | profile_path = home_dir / '.profile' 157 | command = 'echo "echo {}" > {}'.format(shlex.quote(WELCOME_MESSAGE.format(date=date())), profile_path) 158 | subprocess.check_call(['sudo', 'sh', '-c', command]) 159 | 160 | 161 | def is_remote_host_allowed(remote_host: str): 162 | config = get_config() 163 | allowed_networks = config.get('allowed_remote_networks', []) 164 | host_ips = [] 165 | for addrinfo in socket.getaddrinfo(remote_host, 22, proto=socket.IPPROTO_TCP): 166 | host_ips.append(ipaddress.ip_address(addrinfo[4][0])) 167 | for net in allowed_networks: 168 | for host_ip in host_ips: 169 | if host_ip in ipaddress.ip_network(net): 170 | return True 171 | return False 172 | 173 | 174 | def grant_ssh_access(args): 175 | user_name = args.name 176 | 177 | url = get_service_url() 178 | pubkey = download_public_key(url, user_name) 179 | 180 | try: 181 | pwd.getpwnam(user_name) 182 | 183 | except: 184 | config = get_config() 185 | try: 186 | subprocess.check_call(['sudo', 'useradd', 187 | '--user-group', 188 | '--groups', ','.join(config.get('user_groups', ['adm'])), 189 | '--shell', DEFAULT_SHELL, 190 | '--create-home', 191 | # colon is not allowed in the comment field.. 192 | '--comment', USER_COMMENT.format(date=date()).replace(':', '-'), 193 | user_name]) 194 | except: 195 | # out of disk space? try to continue anyway 196 | pass 197 | 198 | try: 199 | keys_file = get_keys_file_path(user_name) 200 | generate_authorized_keys(user_name, keys_file, pubkey) 201 | write_welcome_message(keys_file.parent.parent) 202 | except: 203 | # out of disk space? use fallback and allow login via root 204 | # /root/.ssh/ must be mounted as tmpfs (memory disk) for this to work! 205 | generate_authorized_keys('root', Path('/root/.ssh/authorized_keys'), pubkey) 206 | 207 | if args.remote_host: 208 | if not is_remote_host_allowed(args.remote_host): 209 | raise Exception('Remote host "{}" is not in one of the allowed networks'.format(args.remote_host)) 210 | grant_ssh_access_on_remote_host(user_name, args.remote_host) 211 | 212 | 213 | def execute_ssh(user: str, host: str, command: str): 214 | subprocess.check_call(['ssh', 215 | '-o', 'UserKnownHostsFile=/dev/null', 216 | '-o', 'StrictHostKeyChecking=no', 217 | '-o', 'BatchMode=yes', 218 | '-o', 'ConnectTimeout=10', 219 | '-l', 'granting-service', host, command, user]) 220 | 221 | 222 | def grant_ssh_access_on_remote_host(user: str, host: str): 223 | execute_ssh(user, host, 'grant-ssh-access') 224 | 225 | 226 | def revoke_ssh_access_on_remote_host(user: str, host: str): 227 | execute_ssh(user, host, 'revoke-ssh-access') 228 | 229 | 230 | def is_generated_by_us(keys_file): 231 | '''verify that the user was created by us''' 232 | output = subprocess.check_output(['sudo', 'cat', str(keys_file)]) 233 | return MARKER.encode('utf-8') in output 234 | 235 | 236 | def kill_all_processes(user_name: str): 237 | '''try to write session before killing all processes''' 238 | subprocess.call(['sudo', 'killall', '-u', user_name]) 239 | time.sleep(2) 240 | subprocess.call(['sudo', 'killall', '-KILL', '-u', user_name, '-w']) 241 | 242 | 243 | def revoke_ssh_access(args: list): 244 | user_name = args.name 245 | 246 | if not args.keep_local and user_exists(user_name): 247 | url = get_service_url() 248 | pubkey = download_public_key(url, user_name) 249 | 250 | keys_file = get_keys_file_path(user_name) 251 | 252 | if not is_generated_by_us(keys_file): 253 | raise Exception('Cannot revoke SSH access from user "{}": ' + 254 | 'the user was not created by this script.\n'.format(user_name)) 255 | 256 | forced_command = 'echo {}'.format(shlex.quote(REVOKED_MESSAGE.format(date=date()))) 257 | generate_authorized_keys(user_name, keys_file, pubkey, forced_command) 258 | kill_all_processes(user_name) 259 | 260 | if args.remote_host: 261 | if not is_remote_host_allowed(args.remote_host): 262 | raise Exception('Remote host "{}" is not in one of the allowed networks'.format(args.remote_host)) 263 | revoke_ssh_access_on_remote_host(user_name, args.remote_host) 264 | 265 | 266 | def fail_on_missing_command(): 267 | sys.stderr.write('Missing command argument\n') 268 | sys.exit(1) 269 | 270 | 271 | def user_name(val: str): 272 | '''Validate user name parameter''' 273 | 274 | if not USER_NAME_PATTERN.match(val): 275 | raise argparse.ArgumentTypeError('Invalid user name') 276 | return val 277 | 278 | 279 | def host_name(val: str): 280 | '''Validate host name parameter''' 281 | 282 | if not HOST_NAME_PATTERN.match(val): 283 | raise argparse.ArgumentTypeError('Invalid host name') 284 | return val 285 | 286 | 287 | def main(argv: list): 288 | parser = argparse.ArgumentParser() 289 | subparsers = parser.add_subparsers() 290 | cmd = subparsers.add_parser('grant-ssh-access') 291 | cmd.set_defaults(func=grant_ssh_access) 292 | cmd.add_argument('name', help='User name', type=user_name) 293 | cmd.add_argument('--remote-host', help='Remote host to add user on', type=host_name) 294 | cmd = subparsers.add_parser('revoke-ssh-access') 295 | cmd.set_defaults(func=revoke_ssh_access) 296 | cmd.add_argument('name', help='User name', type=user_name) 297 | cmd.add_argument('--remote-host', help='Remote host to remove user from', type=host_name) 298 | cmd.add_argument('--keep-local', help='Keep local SSH access, only remove on remote host', action='store_true') 299 | args = parser.parse_args(argv) 300 | 301 | if not hasattr(args, 'func'): 302 | fail_on_missing_command() 303 | 304 | syslog.openlog(ident=os.path.basename(__file__), logoption=syslog.LOG_PID, facility=syslog.LOG_AUTH) 305 | syslog.syslog(' '.join(argv)) 306 | try: 307 | args.func(args) 308 | except Exception as e: 309 | sys.stderr.write('ERROR: {}\n'.format(e)) 310 | sys.exit(1) 311 | 312 | 313 | if __name__ == '__main__': 314 | original_command = os.environ.get('SSH_ORIGINAL_COMMAND') 315 | if original_command: 316 | sys.argv[1:] = original_command.split() 317 | 318 | main(sys.argv[1:]) 319 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject org.zalando.stups/even "0.15.0-SNAPSHOT" 2 | :description "SSH access granting service" 3 | :url "https://github.com/zalando-stups/even" 4 | :license {:name "Apache License" 5 | :url "http://www.apache.org/licenses/"} 6 | :scm {:url "git@github.com:zalando-stups/even"} 7 | :min-lein-version "2.0.0" 8 | 9 | :dependencies [[org.clojure/clojure "1.8.0"] 10 | [org.zalando.stups/friboo "1.13.0"] 11 | [org.zalando.stups/tokens "0.11.0-beta-2"] 12 | [metosin/ring-http-response "0.6.5"] 13 | ; SSH client 14 | [clj-ssh "0.5.14"] 15 | 16 | [yesql "0.5.1"] 17 | [squirrel "0.1.2"] 18 | 19 | [org.clojure/data.codec "0.1.0"] 20 | [com.brweber2/clj-dns "0.0.2"]] 21 | 22 | :main ^:skip-aot org.zalando.stups.even.core 23 | :uberjar-name "even.jar" 24 | 25 | :plugins [[lein-environ "1.0.0"] 26 | [lein-cloverage "1.0.6"] 27 | [io.sarnowski/lein-docker "1.1.0"]] 28 | 29 | :docker {:image-name #=(eval (str (some-> (System/getenv "DEFAULT_DOCKER_REGISTRY") 30 | (str "/")) 31 | "stups/even"))} 32 | 33 | :release-tasks [["vcs" "assert-committed"] 34 | ["change" "version" "leiningen.release/bump-version" "release"] 35 | ["vcs" "commit"] 36 | ["vcs" "tag"] 37 | ["clean"] 38 | ["uberjar"] 39 | ["docker" "build"] 40 | ["docker" "push"] 41 | ["change" "version" "leiningen.release/bump-version"] 42 | ["vcs" "commit"] 43 | ["vcs" "push"]] 44 | 45 | :aliases {"cloverage" ["with-profile" "test" "cloverage"]} 46 | 47 | :profiles {:uberjar {:aot :all} 48 | 49 | :test {:dependencies [[clj-http-lite "0.3.0"] 50 | [org.clojure/java.jdbc "0.4.1"]]} 51 | 52 | :dev {:repl-options {:init-ns user} 53 | :source-paths ["dev"] 54 | :dependencies [[org.clojure/tools.namespace "0.2.10"] 55 | [org.clojure/java.classpath "0.2.2"] 56 | [clj-http-lite "0.3.0"] 57 | [midje "1.8.3"] 58 | [org.testcontainers/testcontainers "1.11.2"] 59 | [org.clojure/java.jdbc "0.4.1"]]}}) 60 | 61 | -------------------------------------------------------------------------------- /resources/api/even-api.yaml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | 3 | info: 4 | version: "0.1" 5 | title: even 6 | description: SSH access granting service 7 | 8 | basePath: / 9 | 10 | consumes: 11 | - application/json 12 | produces: 13 | - application/json 14 | 15 | definitions: 16 | AccessRequest: 17 | type: object 18 | required: 19 | - hostname 20 | - reason 21 | properties: 22 | username: 23 | type: string 24 | description: The user to request access for 25 | pattern: "^[a-z][a-z0-9-]{0,31}$" 26 | example: jdoe 27 | hostname: 28 | type: string 29 | description: The host to request access at 30 | pattern: "^[a-z0-9.-]{0,255}$" 31 | example: my-host.example.org 32 | reason: 33 | type: string 34 | description: The request reason 35 | example: Troubleshoot problem XY 36 | remote_host: 37 | type: string 38 | description: Private remote host to gain access to 39 | example: 172.31.1.1 40 | pattern: "^[a-z0-9.-]{0,255}$" 41 | lifetime_minutes: 42 | type: integer 43 | description: "Lifetime of the access request in minutes (default: 60)" 44 | example: 60 45 | minimum: 1 46 | maximum: 525600 # 60*24*365 = 1 year 47 | 48 | paths: 49 | 50 | '/': 51 | get: 52 | summary: Application root 53 | operationId: org.zalando.stups.friboo.system.http/redirect-to-swagger-ui 54 | security: 55 | - oauth2: [uid] 56 | responses: 57 | default: 58 | description: "Redirects to /ui/" 59 | 60 | /public-keys/{name}/sshkey.pub: 61 | get: 62 | summary: Download public SSH key 63 | description: Return public SSH key as string, usable for authorized_keys file of OpenSSH. 64 | operationId: org.zalando.stups.even.api/serve-public-key 65 | tags: 66 | - PublicKeys 67 | parameters: 68 | - name: name 69 | in: path 70 | description: User name 71 | required: true 72 | type: string 73 | pattern: "^[a-z][a-z0-9-]{0,31}$" 74 | produces: 75 | - text/plain 76 | responses: 77 | 200: 78 | description: SSH key found 79 | schema: 80 | type: string 81 | title: PublicSshKey 82 | 404: 83 | description: User and/or his SSH key cannot be found 84 | 400: 85 | description: Invalid username parameter 86 | 87 | 88 | /access-requests: 89 | get: 90 | summary: List most recent access requests 91 | operationId: org.zalando.stups.even.api/list-access-requests 92 | tags: 93 | - AccessRequests 94 | security: 95 | - oauth2: [uid] 96 | parameters: 97 | - name: limit 98 | description: Maximum number of results to return 99 | in: query 100 | type: integer 101 | required: false 102 | - name: offset 103 | description: Offset of results 104 | in: query 105 | type: integer 106 | required: false 107 | - name: status 108 | description: Filter requests by status 109 | in: query 110 | type: string 111 | required: false 112 | - name: hostname 113 | description: Filter requests by hostname 114 | in: query 115 | type: string 116 | required: false 117 | - name: username 118 | description: Filter requests by username 119 | in: query 120 | type: string 121 | required: false 122 | responses: 123 | 200: 124 | description: List of access requests 125 | schema: 126 | type: array 127 | items: 128 | $ref: "#/definitions/AccessRequest" 129 | post: 130 | summary: Request SSH access to a single host 131 | description: | 132 | Request SSH access to a single host. 133 | The "hostname" property usually points to a "odd" SSH bastion host. 134 | The "remote_host" property usually points to a private EC2 instance. 135 | operationId: org.zalando.stups.even.api/request-access 136 | tags: 137 | - AccessRequests 138 | security: 139 | - oauth2: [uid] 140 | parameters: 141 | # An example parameter that is in query and is required 142 | - name: request 143 | in: body 144 | description: Access Request 145 | required: true 146 | schema: 147 | $ref: "#/definitions/AccessRequest" 148 | 149 | # Expected responses for this operation: 150 | responses: 151 | # Response code 152 | 200: 153 | description: Successful response 154 | # A schema describing your response object. 155 | # Use JSON Schema format 156 | schema: 157 | title: SuccessMessage 158 | type: string 159 | example: Access to host XY for user ABC was granted. 160 | 400: 161 | description: Invalid request, please check your data. 162 | 401: 163 | description: Unauthorized, please authenticate. 164 | 403: 165 | description: Forbidden, you are not allowed to gain SSH access to the specified host. 166 | 167 | securityDefinitions: 168 | oauth2: 169 | type: oauth2 170 | flow: implicit 171 | authorizationUrl: https://example.com/oauth2/dialog 172 | scopes: 173 | uid: Unique identifier of the user accessing the service. 174 | -------------------------------------------------------------------------------- /resources/db/even.sql: -------------------------------------------------------------------------------- 1 | -- name: list-access-requests 2 | SELECT * 3 | FROM access_requests 4 | WHERE ar_status = COALESCE((:status)::access_request_status, ar_status) 5 | AND ar_hostname = COALESCE(:hostname, ar_hostname) 6 | AND ar_username = COALESCE(:username, ar_username) 7 | ORDER BY ar_id DESC 8 | LIMIT :limit 9 | OFFSET :offset 10 | 11 | -- name: get-expired-access-requests 12 | SELECT * 13 | FROM access_requests 14 | WHERE (ar_status = 'GRANTED' 15 | OR (ar_status = 'EXPIRED' AND now() > ar_last_modified + interval '5 minutes')) 16 | AND now() > ar_created + (ar_lifetime_minutes * interval '1 minute') 17 | ORDER BY ar_id ASC 18 | 19 | -- name: count-remaining-granted-access-requests 20 | -- return the number of remaining SSH access "connections" to the given hostname excluding the given request ID 21 | SELECT COUNT(*) AS count 22 | FROM access_requests 23 | WHERE ar_status = 'GRANTED' 24 | AND ar_hostname = :hostname 25 | AND ar_username = :username 26 | AND ar_id != :id 27 | 28 | -- name: create-access-request 29 | INSERT INTO access_requests 30 | (ar_username, ar_hostname, ar_reason, ar_remote_host, ar_lifetime_minutes, ar_created_by) 31 | VALUES (:username, :hostname, :reason, :remote_host, :lifetime_minutes, :created_by) 32 | RETURNING ar_id 33 | 34 | -- name: update-access-request! 35 | UPDATE access_requests 36 | SET ar_status = (:status)::access_request_status, 37 | ar_status_reason = :status_reason, 38 | ar_last_modified = now(), 39 | ar_last_modified_by = :last_modified_by 40 | WHERE ar_id = :id 41 | 42 | -- name: acquire-lock 43 | INSERT INTO locks 44 | (l_resource_name, l_created_by) 45 | VALUES (:resource_name, :created_by) 46 | RETURNING l_id, l_resource_name 47 | 48 | -- name: release-lock! 49 | DELETE FROM locks 50 | WHERE l_resource_name = :resource_name 51 | AND l_id = :id 52 | 53 | -- name: clean-up-old-locks! 54 | DELETE FROM locks 55 | WHERE now() > l_created + interval '1 hour' 56 | -------------------------------------------------------------------------------- /resources/db/migration/V1__Basic_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA ze_data; 2 | SET search_path TO ze_data; 3 | 4 | CREATE TYPE access_request_status AS ENUM ( 5 | 'REQUESTED', 6 | 'GRANTED', 7 | 'DENIED', 8 | 'FAILED', 9 | 'EXPIRED', 10 | 'REVOKED' 11 | ); 12 | 13 | CREATE TABLE access_requests ( 14 | ar_id serial PRIMARY KEY, 15 | ar_username TEXT NOT NULL, 16 | ar_hostname TEXT NOT NULL, 17 | ar_reason TEXT NOT NULL, 18 | ar_remote_host TEXT, 19 | ar_status access_request_status NOT NULL DEFAULT 'REQUESTED', 20 | ar_status_reason TEXT, 21 | ar_lifetime_minutes INTEGER NOT NULL, 22 | ar_created timestamp NOT NULL DEFAULT now(), 23 | ar_created_by TEXT, 24 | ar_last_modified timestamp NOT NULL DEFAULT now(), 25 | ar_last_modified_by TEXT, 26 | 27 | CONSTRAINT username_pattern CHECK (ar_username ~ '^[a-z][a-z0-9-]{0,31}$'), 28 | CONSTRAINT hostname_pattern CHECK (ar_hostname ~ '^[a-z0-9.-]{0,255}$'), 29 | CONSTRAINT remote_host_pattern CHECK (ar_remote_host ~ '^[a-z0-9.-]{0,255}$'), 30 | CONSTRAINT lifetime_minutes_range CHECK (ar_lifetime_minutes > 0) 31 | ); 32 | 33 | CREATE INDEX access_requests_status_idx ON access_requests (ar_status); 34 | 35 | CREATE TABLE locks ( 36 | l_id serial PRIMARY KEY, 37 | l_resource_name TEXT NOT NULL UNIQUE, 38 | l_created timestamp NOT NULL DEFAULT now(), 39 | l_created_by TEXT 40 | ); -------------------------------------------------------------------------------- /resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/org/zalando/stups/even/api.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.api 2 | (:require 3 | [org.zalando.stups.friboo.system.http :refer [def-http-component]] 4 | [clojure.tools.logging :as log] 5 | [schema.core :as s] 6 | [bugsbio.squirrel :as sq] 7 | [org.zalando.stups.even.pubkey-provider.usersvc :refer [get-public-key]] 8 | [org.zalando.stups.even.ssh :refer [execute-ssh]] 9 | [org.zalando.stups.even.sql :as sql] 10 | [org.zalando.stups.even.audit :as audit] 11 | [clojure.data.codec.base64 :as b64] 12 | [ring.util.http-response :as http] 13 | [ring.util.response :as ring] 14 | [org.zalando.stups.friboo.ring :as fring] 15 | [org.zalando.stups.friboo.user :as u] 16 | [org.zalando.stups.friboo.config :refer [require-config]] 17 | [clj-dns.core :as dns]) 18 | (:import [clojure.lang ExceptionInfo] 19 | [java.util.regex Pattern])) 20 | 21 | 22 | ; most validations are now already done by Swagger1st! 23 | (defn valid-lifetime [i] (and (pos? i) (<= i 525600))) 24 | 25 | ; most validations are now already done by Swagger1st! 26 | (s/defschema AccessRequest 27 | {(s/optional-key :username) s/Str 28 | :hostname s/Str 29 | :reason s/Str 30 | (s/optional-key :remote_host) s/Str 31 | (s/optional-key :lifetime_minutes) (s/both s/Int (s/pred valid-lifetime))}) 32 | 33 | 34 | (def-http-component API "api/even-api.yaml" [ssh db usersvc http-audit-logger] :dependencies-as-map true) 35 | 36 | (def default-http-configuration {:http-port 8080}) 37 | 38 | (def empty-access-request {:username nil :hostname nil :reason nil :remote_host nil :lifetime_minutes 60}) 39 | 40 | (defn serve-public-key 41 | "Return the user's public SSH key as plaintext" 42 | [{:keys [name]} request {:keys [usersvc]}] 43 | (if-let [ssh-key (get-public-key name usersvc)] 44 | (-> (ring/response ssh-key) 45 | (ring/header "Content-Type" "text/plain")) 46 | (http/not-found "User not found"))) 47 | 48 | (defn ensure-username [auth {:keys [username] :as req}] 49 | (assoc req :username (or username (:username auth)))) 50 | 51 | (defn ensure-request-keys 52 | "Ensure that all access request keys exist in the given map" 53 | [request] 54 | (merge empty-access-request request)) 55 | 56 | (defn extract-auth 57 | "Extract UID and team membership from ring request" 58 | [req] 59 | (if-let [uid (get-in req [:tokeninfo "uid"])] 60 | {:username uid 61 | :teams (u/require-teams req)})) 62 | 63 | (defn resolve-hostname [hostname] 64 | (try 65 | (dns/to-inet-address hostname) 66 | (catch Exception e 67 | (throw (ex-info (str "Could not resolve hostname " hostname ": " (.getMessage e)) {:http-code 400}))))) 68 | 69 | (def team-placeholder (Pattern/quote "{team}")) 70 | 71 | (defn get-allowed-hostnames [{:keys [username teams]} ring-request] 72 | (let [hostname-template (require-config (:configuration ring-request) :allowed-hostname-template)] 73 | (map #(.replaceAll hostname-template team-placeholder %) teams))) 74 | 75 | (defn request-access-with-auth 76 | "Request server access with provided auth credentials" 77 | [auth {:keys [hostname username remote_host reason] :as access-request} ring-request ssh db usersvc {:keys [log-fn]}] 78 | (log/info "Requesting access to " username "@" hostname ", remote-host=" remote_host ", reason=" reason) 79 | (let [ip (resolve-hostname hostname) 80 | auth-user (:username auth) 81 | allowed-hostnames (get-allowed-hostnames auth ring-request) 82 | matching-hostnames (filter #(.matches hostname %) allowed-hostnames) 83 | handle (sql/from-sql (first (sql/cmd-create-access-request (sq/to-sql (assoc access-request :created-by auth-user)) {:connection db})))] 84 | (if (empty? matching-hostnames) 85 | (let [msg (str "Forbidden. Host " ip " is not matching any allowed hostname: " (print-str allowed-hostnames))] 86 | (sql/update-access-request-status handle "DENIED" msg auth-user db) 87 | (http/forbidden msg)) 88 | (let [result (execute-ssh hostname (str "grant-ssh-access --remote-host=" remote_host " " username) ssh)] 89 | (if (zero? (:exit result)) 90 | (let [msg (str "Access to host " ip " for user " username " was granted.")] 91 | (sql/update-access-request-status handle "GRANTED" msg auth-user db) 92 | (log-fn (audit/create-event auth access-request ip allowed-hostnames)) 93 | (http/ok msg)) 94 | (let [msg (str "SSH command failed: " (or (:err result) (:out result)))] 95 | (sql/update-access-request-status handle "FAILED" msg auth-user db) 96 | (http/bad-request msg))))))) 97 | 98 | (defn validate-request 99 | "Validate the given access request" 100 | [request] 101 | (try (s/validate AccessRequest request) 102 | (catch ExceptionInfo e 103 | (throw (ex-info (str "Invalid request: " (.getMessage e)) {:http-code 400}))))) 104 | 105 | (defn request-access 106 | "Request SSH access to a specific host" 107 | [{:keys [request]} ring-request {:keys [ssh db usersvc http-audit-logger]}] 108 | (if-let [auth (extract-auth ring-request)] 109 | (request-access-with-auth 110 | auth 111 | (->> request 112 | validate-request 113 | (ensure-username auth) 114 | ensure-request-keys) 115 | ring-request 116 | ssh 117 | db 118 | usersvc 119 | http-audit-logger) 120 | (http/unauthorized "Unauthorized. Please authenticate with a valid OAuth2 token."))) 121 | 122 | (defn list-access-requests 123 | "Return list of most recent access requests from database" 124 | [parameters _ {:keys [db]}] 125 | (let [result (map sql/from-sql (sql/cmd-list-access-requests (sq/to-sql parameters) {:connection db}))] 126 | (-> (ring/response result) 127 | (fring/content-type-json)))) 128 | -------------------------------------------------------------------------------- /src/org/zalando/stups/even/audit.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.audit 2 | (:require [clj-time.format :as tf] 3 | [clojure.string :as str] 4 | [clj-time.core :as t])) 5 | 6 | (def date-formatter 7 | (tf/formatters :date-time)) 8 | 9 | (defn get-date 10 | [] 11 | (tf/unparse date-formatter (t/now))) 12 | 13 | (defn drop-nil-values 14 | [record] 15 | (into {} (remove (comp nil? second) record))) 16 | 17 | (defn create-event 18 | [auth access-request ip allowed-hostnames] 19 | {:event_type {:namespace "cloud.zalando.com" 20 | :name "request-ssh-access" 21 | :version "1.1"} 22 | :triggered_at (get-date) 23 | :triggered_by {:type "EMPLOYEE_USERNAME" 24 | :id (:username auth)} 25 | :payload (drop-nil-values 26 | {:hostname (:hostname access-request) 27 | :host_ip (.getHostAddress ip) 28 | :reason (:reason access-request) 29 | :remote_host (:remote_host access-request) 30 | :access_request_lifetime (:lifetime_minutes access-request) 31 | :allowed_hostnames (str/join "," allowed-hostnames)})}) 32 | -------------------------------------------------------------------------------- /src/org/zalando/stups/even/core.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.core 2 | (:gen-class) 3 | (:require [com.stuartsierra.component :refer [using]] 4 | [environ.core :refer [env]] 5 | [org.zalando.stups.friboo.config :as config] 6 | [org.zalando.stups.friboo.system :as system :refer [http-system-map default-http-namespaces-and]] 7 | [org.zalando.stups.friboo.log :as log] 8 | [org.zalando.stups.even.sql :as sql] 9 | [org.zalando.stups.even.api :as api] 10 | [org.zalando.stups.friboo.system.oauth2 :as oauth2] 11 | [org.zalando.stups.friboo.system.audit-logger.http :as http-logger] 12 | [org.zalando.stups.even.job :as job] 13 | [org.zalando.stups.even.pubkey-provider.usersvc :refer [new-usersvc]] 14 | [org.zalando.stups.even.ssh :refer [new-ssh default-ssh-configuration]])) 15 | 16 | 17 | (defn new-system 18 | "Returns a new instance of the whole application" 19 | [{:keys [ssh db jobs oauth2 usersvc] :as config}] 20 | (http-system-map config 21 | api/map->API [:ssh :db :usersvc :http-audit-logger] 22 | :db (sql/map->DB {:configuration db}) 23 | :http-audit-logger (using 24 | (http-logger/map->HTTP {:configuration (assoc (:httplogger config) 25 | :token-name "http-audit-logger")}) 26 | [:tokens]) 27 | :tokens (oauth2/map->OAuth2TokenRefresher {:configuration oauth2 28 | :tokens {"user-service" ["uid"] 29 | "http-audit-logger" ["uid"]}}) 30 | :usersvc (using (new-usersvc usersvc) [:tokens]) 31 | :ssh (new-ssh ssh) 32 | :jobs (using (job/map->Jobs {:configuration jobs}) [:ssh :db]))) 33 | 34 | (defn run 35 | "Initializes and starts the whole system." 36 | [default-configuration] 37 | (let [configuration (config/load-configuration 38 | (default-http-namespaces-and :ssh :db :jobs :oauth2 :usersvc :httplogger) 39 | [api/default-http-configuration 40 | default-ssh-configuration 41 | sql/default-db-configuration 42 | job/default-configuration 43 | default-configuration]) 44 | 45 | system (new-system configuration)] 46 | (system/run configuration system))) 47 | 48 | (defn -main 49 | "The actual main for our uberjar." 50 | [& args] 51 | (try 52 | (run {}) 53 | (catch Exception e 54 | (log/error e "Could not start system because of %s." (str e)) 55 | (System/exit 1)))) 56 | 57 | -------------------------------------------------------------------------------- /src/org/zalando/stups/even/job.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.job 2 | (:require [org.zalando.stups.friboo.system.cron :refer [def-cron-component job]] 3 | [org.zalando.stups.friboo.log :as log] 4 | [org.zalando.stups.even.sql :as sql] 5 | [org.zalando.stups.even.ssh :refer [execute-ssh]] 6 | [overtone.at-at :refer [every]])) 7 | 8 | 9 | (def default-configuration 10 | {:jobs-cpu-count 1 11 | :jobs-every-ms 30000 12 | :jobs-initial-delay-ms 1000}) 13 | 14 | (def one-minute-millis 15 | (* 60 1000)) 16 | 17 | (defn get-revoke-ssh-access-options 18 | "Return command line options for the SSH forced command script" 19 | [remote_host username remaining-count] 20 | (-> ["revoke-ssh-access" username] 21 | (concat (if (nil? remote_host) [] ["--remote-host" remote_host])) 22 | (concat (if (pos? remaining-count) ["--keep-local"] [])))) 23 | 24 | (defn retry-revocation-without-remote-host? 25 | "Should we retry the revoke-ssh-access command without remote host (e.g. because it was shut down)?" 26 | [{:keys [created lifetime_minutes status status_reason]}] 27 | (let [age_minutes (/ (- (System/currentTimeMillis) (.getTime created)) one-minute-millis) 28 | expiration_minutes (- age_minutes lifetime_minutes) 29 | expired_for_more_than_one_hour (> expiration_minutes 60) 30 | was_timeout (and (= status "EXPIRED") (.contains status_reason "Connection timed out"))] 31 | (and expired_for_more_than_one_hour was_timeout))) 32 | 33 | (defn revoke-ssh-access 34 | "Revoke SSH access to the given host/remote host" 35 | [ssh db {:keys [id hostname remote_host username] :as req}] 36 | (let [remaining-count (:count (first (sql/count-remaining-granted-access-requests {:hostname hostname :username username :id id} {:connection db}))) 37 | remote_host_or_nil (when-not (retry-revocation-without-remote-host? req) remote_host) 38 | options (get-revoke-ssh-access-options remote_host_or_nil username remaining-count)] 39 | (execute-ssh hostname (clojure.string/join " " options) ssh))) 40 | 41 | (defn revoke-expired-access-request 42 | "Revoke a single expired access request" 43 | [ssh db {:keys [hostname remote_host username] :as req}] 44 | (log/info "Revoking expired access request %s.." req) 45 | (let [result (revoke-ssh-access ssh db req)] 46 | (if (zero? (:exit result)) 47 | (let [msg (str "Access to host " hostname " for user " username " was revoked.")] 48 | (sql/update-access-request-status req "REVOKED" msg "job" db) 49 | (log/info msg)) 50 | (let [msg (str "SSH revocation command failed: " (or (:err result) (:out result)))] 51 | (sql/update-access-request-status req "EXPIRED" msg "job" db) 52 | (log/warn msg))))) 53 | 54 | (defn revoke-expired-access-requests 55 | "Revoke all expired access requests" 56 | [ssh db] 57 | (let [expired-requests (map sql/from-sql (sql/get-expired-access-requests {} {:connection db}))] 58 | (log/info "Revoking %s expired access requests.." (count expired-requests)) 59 | (doseq [req expired-requests] 60 | (sql/update-access-request-status req "EXPIRED" "Request lifetime exceeded" "job" db) 61 | (revoke-expired-access-request ssh db req)))) 62 | 63 | (defn acquire-lock [db] 64 | (try 65 | (sql/from-sql (first (sql/acquire-lock {:resource_name "revoke-expired-access-requests" :created_by "job"} {:connection db}))) 66 | (catch Exception e 67 | (if (.contains (str e) "duplicate key value violates unique constraint") 68 | (log/info "Could not acquire lock: resource already locked") 69 | (throw e))))) 70 | 71 | (defn run-revoke-expired-access-requests 72 | "CRON job to cleanup locks and expire access requests" 73 | [ssh db] 74 | (try 75 | (sql/clean-up-old-locks! {} {:connection db}) 76 | (if-let [lock (acquire-lock db)] 77 | (try 78 | (revoke-expired-access-requests ssh db) 79 | (finally (sql/release-lock! lock {:connection db})))) 80 | ; IMPORTANT: we need to catch all Throwables because yesql uses "assert" in some cases 81 | (catch Throwable e 82 | (log/error e "Caught exception while executing CRON job: %s" (str e))))) 83 | 84 | (def-cron-component 85 | Jobs [ssh db] 86 | 87 | (let [{:keys [every-ms initial-delay-ms]} configuration] 88 | (every every-ms (job run-revoke-expired-access-requests ssh db) pool 89 | :initial-delay initial-delay-ms 90 | :desc "revoke expired access requests"))) 91 | -------------------------------------------------------------------------------- /src/org/zalando/stups/even/pubkey_provider/usersvc.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.pubkey-provider.usersvc 2 | (:require 3 | [clojure.tools.logging :as log] 4 | [clj-http.client :as client] 5 | [org.zalando.stups.friboo.system.oauth2 :as oauth2] 6 | [com.netflix.hystrix.core :refer [defcommand]] 7 | [org.zalando.stups.friboo.config :as config] 8 | [clojure.java.io :as io] 9 | [amazonica.aws.s3 :as s3]) 10 | (:import [java.util.regex Pattern] 11 | [java.util UUID] 12 | [com.amazonaws.services.s3.model AmazonS3Exception])) 13 | 14 | (defrecord Usersvc [config tokens]) 15 | 16 | (def user-placeholder (Pattern/quote "{user}")) 17 | 18 | (defcommand get-public-key-from-service 19 | "Get a user's public SSH key from HTTP service" 20 | [name {:keys [config tokens] :as usersvc}] 21 | (let [template (config/require-config config :ssh-public-key-url-template) 22 | url (.replaceAll template user-placeholder name)] 23 | (:body (client/get url 24 | {:oauth-token (oauth2/access-token "user-service" tokens)})))) 25 | 26 | (defn get-s3-key [name] 27 | "Get S3 object key from public key name" 28 | (str "public-keys/" name ".pub")) 29 | 30 | (defcommand get-public-key-from-s3 31 | "Try to load SSH public key from S3 cache bucket" 32 | [name {:keys [config] :as usersvc}] 33 | (try 34 | (let [bucket (config/require-config config :cache-bucket) 35 | result (s3/get-object :bucket-name bucket 36 | :key (get-s3-key name))] 37 | (slurp (:input-stream result))) 38 | (catch AmazonS3Exception se 39 | ; just return null if the S3 object does not exist 40 | (when-not (= 404 (.getStatusCode se)) 41 | (throw se))))) 42 | 43 | (defcommand store-public-key-on-s3 44 | "Store SSH public key in S3 cache bucket" 45 | [name public-key {:keys [config] :as usersvc}] 46 | (let [bucket (config/require-config config :cache-bucket) 47 | ^File tmp-file (io/file "/tmp" (str name ".tmp-" (UUID/randomUUID)))] 48 | (spit tmp-file public-key) 49 | (s3/put-object :bucket-name bucket 50 | :key (get-s3-key name) 51 | :file tmp-file) 52 | (io/delete-file tmp-file true))) 53 | 54 | (defn get-public-key 55 | "Get user's public SSH key, first try HTTP service, then S3 cache bucket" 56 | [name {:keys [config] :as usersvc}] 57 | (try 58 | (let [public-key (get-public-key-from-service name usersvc)] 59 | (when (:cache-bucket config) 60 | (store-public-key-on-s3 name public-key usersvc)) 61 | public-key) 62 | (catch Throwable ex 63 | (if (:cache-bucket config) 64 | (do 65 | (log/warn "Failed to get SSH public key from HTTP service, falling back to S3 cache bucket:" (.getMessage ex) (when (.getCause ex) (.getMessage (.getCause ex)))) 66 | (get-public-key-from-s3 name usersvc)) 67 | (throw ex))))) 68 | 69 | (defn ^Usersvc new-usersvc [config] 70 | (log/info "Configuring User Service with" (config/mask config)) 71 | (map->Usersvc {:config config})) 72 | -------------------------------------------------------------------------------- /src/org/zalando/stups/even/sql.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.sql 2 | (:require [yesql.core :refer [defqueries]] 3 | [org.zalando.stups.friboo.system.db :refer [def-db-component generate-hystrix-commands]] 4 | [bugsbio.squirrel :as sq] 5 | [com.netflix.hystrix.core :refer [defcommand]])) 6 | 7 | (def-db-component DB :auto-migration? true) 8 | 9 | (def default-db-configuration 10 | {:db-classname "org.postgresql.Driver" 11 | :db-subprotocol "postgresql" 12 | :db-subname "//localhost:5432/even" 13 | :db-user "postgres" 14 | :db-password "postgres" 15 | :db-init-sql "SET search_path TO ze_data, public"}) 16 | 17 | (defqueries "db/even.sql") 18 | (generate-hystrix-commands) 19 | 20 | (defn strip-prefix 21 | "Strip the database table prefix from the given key" 22 | [key] 23 | (-> key 24 | name 25 | (.split "_") 26 | rest 27 | (#(clojure.string/join "_" %)) 28 | keyword)) 29 | 30 | (defn from-sql 31 | "Transform a database result row to a valid result object: strip table prefix from column names" 32 | [row] 33 | (zipmap (map strip-prefix (keys row)) (vals row))) 34 | 35 | ; TODO still necessary since we have the cmd- wrappers? 36 | (defcommand update-access-request-status 37 | "Update access request status in database" 38 | [handle status reason user db] 39 | (update-access-request! (sq/to-sql (merge handle {:status status :status-reason reason :last-modified-by user})) {:connection db})) 40 | -------------------------------------------------------------------------------- /src/org/zalando/stups/even/ssh.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.ssh 2 | (:require [clojure.tools.logging :as log] 3 | [clj-ssh.ssh :refer :all] 4 | [clojure.java.io :as io] 5 | [org.zalando.stups.friboo.config :as config] 6 | [com.netflix.hystrix.core :refer [defcommand]] 7 | [clojure.string :as str]) 8 | (:import 9 | [java.nio.file.attribute PosixFilePermissions] 10 | [java.nio.file.attribute FileAttribute] 11 | [java.nio.file Files] 12 | (java.util UUID) 13 | (java.nio.charset StandardCharsets) 14 | (com.jcraft.jsch JSch))) 15 | 16 | (defrecord Ssh [config]) 17 | 18 | (def default-ssh-configuration {:ssh-user "granting-service" 19 | :ssh-port 22 20 | :ssh-agent-forwarding true 21 | :ssh-timeout 24000}) 22 | 23 | (def owner-only 24 | "Java file attributes for 'owner-only' permissions" 25 | (into-array FileAttribute [(PosixFilePermissions/asFileAttribute (PosixFilePermissions/fromString "rwx------"))])) 26 | 27 | (defn write-key-to-file 28 | "Write private SSH key to a temp file, only readable by our user" 29 | [key] 30 | (let [key-filename (format "%s.pem" (UUID/randomUUID)) 31 | path (str (Files/createTempFile "ssh-private-key" key-filename owner-only))] 32 | (log/info "Writing SSH private key to" path) 33 | (spit path key) 34 | path)) 35 | 36 | (defn split-keys 37 | "Extract all keys contained in a single string into a sequence of keys" 38 | [key-str] 39 | (->> (str/split key-str #"(?=-----BEGIN)") 40 | (map str/trim) 41 | (filter not-empty))) 42 | 43 | (defn write-private-keys 44 | "Takes a string containing multiple private keys, writes each of them to an individual file and returns 45 | a sequence of paths to these files" 46 | [key-str] 47 | (map write-key-to-file (split-keys key-str))) 48 | 49 | (defn set-timeout [session timeout] 50 | (.setTimeout session timeout)) 51 | 52 | (defn execute-ssh 53 | "Execute the given command on the remote host using the configured SSH user and private key" 54 | [hostname command {{:keys [user private-keys private-key-password port agent-forwarding timeout]} :config}] 55 | (log/info "ssh" user "@" hostname command) 56 | (let [agent (ssh-agent {:use-system-ssh-agent false 57 | :known-hosts-path "/dev/null"}) 58 | private-key-paths (write-private-keys private-keys)] 59 | (doseq [path private-key-paths] 60 | (add-identity agent {:private-key-path path 61 | :passphrase (some-> private-key-password 62 | (.getBytes StandardCharsets/US_ASCII))})) 63 | (let [session (session agent hostname {:username user 64 | :port port 65 | :strict-host-key-checking :no})] 66 | (set-timeout session timeout) 67 | (try 68 | (with-connection session 69 | (let [result (ssh session {:cmd command 70 | :agent-forwarding agent-forwarding})] 71 | (log/info "Result: " result) 72 | result)) 73 | (catch Exception e 74 | {:exit 255 :err (.getMessage e) :out ""}) 75 | (finally 76 | (doseq [path private-key-paths] 77 | (io/delete-file path true))))))) 78 | 79 | 80 | (defn ^Ssh new-ssh [config] 81 | (log/info "Configuring SSH with" (config/mask config)) 82 | (map->Ssh {:config config})) 83 | -------------------------------------------------------------------------------- /test/org/zalando/stups/even/api_test.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.api-test 2 | 3 | (:import java.net.InetAddress) 4 | (:require [clojure.test :refer :all] 5 | [org.zalando.stups.even.api :refer :all] 6 | [midje.sweet :refer :all] 7 | [schema.core :as s] 8 | [clojure.string :as str] 9 | [org.zalando.stups.even.ssh :as ssh] 10 | [org.zalando.stups.even.sql :as sql] 11 | [org.zalando.stups.even.api :as api] 12 | [org.zalando.stups.even.audit :as audit] 13 | )) 14 | 15 | (deftest test-access-request-validation-fails 16 | (are [req] (thrown? Exception (validate-request req)) 17 | {} 18 | {:username "a"} 19 | {:hostname "a"} 20 | )) 21 | 22 | (deftest test-access-request-validation-succeeds 23 | (are [req] (= req (validate-request req)) 24 | ; username is optional 25 | {:hostname "b" :reason "a"} 26 | {:username "my-user" :hostname "some.host" :reason "test"} 27 | {:username "my-user" :hostname "1.2.3.4" :reason "test"} 28 | )) 29 | 30 | (deftest test-ensure-username 31 | (is (= {:username "a" :blub "b"} (ensure-username {:username "a"} {:blub "b"})))) 32 | 33 | (deftest test-request-access-wrong-network 34 | (with-redefs [get-allowed-hostnames (constantly ["odd-.*.myteam.example.org"]) 35 | sql/create-access-request (constantly []) 36 | sql/update-access-request! (constantly nil)] 37 | (is (= {:status 403 :headers {} :body "Forbidden. Host /2.3.4.5 is not matching any allowed hostname: [odd-.*.myteam.example.org]"} 38 | (request-access-with-auth 39 | {:username "user1" :teams ["myteam"]} 40 | {:hostname "2.3.4.5"} 41 | {:configuration {:allowed-hostname-template "odd-.*.{team}.example.org"}} 42 | {} 43 | {} 44 | {} 45 | (constantly nil) 46 | ))))) 47 | 48 | (deftest test-request-access-success 49 | (with-redefs [sql/create-access-request (constantly []) 50 | sql/update-access-request! (constantly nil) 51 | resolve-hostname (constantly "odd-eu-west-1.myteam.example.org/127.0.0.1") 52 | ssh/execute-ssh (constantly {:exit 0}) 53 | audit/create-event (constantly {})] 54 | (is (= {:status 200 :headers {} :body "Access to host odd-eu-west-1.myteam.example.org/127.0.0.1 for user user1 was granted."} 55 | (request-access-with-auth 56 | {:username "user1" :teams ["myteam"]} 57 | {:hostname "odd-eu-west-1.myteam.example.org" :username "user1"} 58 | {:configuration {:allowed-hostname-template "odd-.*.{team}.example.org"}} 59 | {} 60 | {} 61 | {} 62 | {:log-fn (constantly nil)}))))) 63 | 64 | (deftest test-request-no-auth 65 | (is (= {:status 401 :headers {} :body "Unauthorized. Please authenticate with a valid OAuth2 token."} 66 | (request-access 67 | {:request {:hostname "someStr" :reason "someStr"}} 68 | {} 69 | {:ssh nil :db nil :usersvc nil :http-audit-logger nil})))) 70 | 71 | (deftest ^:unit test-log-fn-being-called-or-not 72 | (defn log-fn [] nil) 73 | (def inet-address (InetAddress/getByName "www.name.de")) 74 | 75 | (facts "about log-fn" 76 | 77 | (fact "it is being called on successfull handling of ssh access request" 78 | (api/request-access-with-auth .auth. {:hostname "www.name.de"} .ring-request. .ssh. .db. .usersvc. {:log-fn log-fn}) => (contains {:status 200}) 79 | (provided 80 | .auth. =contains=> {:username "userx" :teams '("someteam")} 81 | .ring-request. =contains=> {:configuration {:allowed-hostname-template "www.name.de"}} 82 | (api/resolve-hostname anything) => inet-address 83 | (sql/cmd-create-access-request anything anything) => '() 84 | (ssh/execute-ssh anything anything .ssh.) => {:exit 0} 85 | (sql/update-access-request-status anything anything anything anything anything ) => irrelevant 86 | (audit/create-event .auth. {:hostname "www.name.de"} inet-address '("www.name.de")) => .created-event. 87 | (log-fn .created-event.) => {} :times 1)) 88 | 89 | (fact "it is never called if executing ssh command returns with error" 90 | (api/request-access-with-auth .auth. {:hostname "www.name.de"} .ring-request. .ssh. .db. .usersvc. {:log-fn log-fn}) => (contains {:status 400}) 91 | (provided 92 | .auth. =contains=> {:username "userx" :teams '("someteam")} 93 | .ring-request. =contains=> {:configuration {:allowed-hostname-template "www.name.de"}} 94 | (api/resolve-hostname anything) => .ip. 95 | (sql/cmd-create-access-request anything anything) => '() 96 | (ssh/execute-ssh anything anything .ssh.) => {:exit 1} 97 | (sql/update-access-request-status anything anything anything anything anything ) => irrelevant 98 | (log-fn anything) => {} :times 0)) 99 | 100 | (fact "it is never called if no matching hostname was found" 101 | (api/request-access-with-auth .auth. {:hostname "www.name.de"} .ring-request. .ssh. .db. .usersvc. log-fn) => (contains {:status 403}) 102 | (provided 103 | .auth. =contains=> {:username "userx"} 104 | .ring-request. =contains=> {:configuration {:allowed-hostname-template "not matching"}} 105 | (api/resolve-hostname anything) => .ip. 106 | (sql/cmd-create-access-request anything anything) => '() 107 | (sql/update-access-request-status anything anything anything anything anything) => irrelevant 108 | (log-fn anything) => {} :times 0)))) 109 | 110 | -------------------------------------------------------------------------------- /test/org/zalando/stups/even/audit_test.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.audit-test 2 | (:import java.net.InetAddress) 3 | (:require [clojure.test :refer :all] 4 | [midje.sweet :refer :all] 5 | [org.zalando.stups.even.audit :as audit])) 6 | 7 | (deftest ^:unit test-create-event 8 | (facts "about creating audittrail events" 9 | (fact "creates valid audittrail event with all possible data" 10 | (audit/create-event 11 | {:username "authUserName"} 12 | {:hostname "hostname" :reason "schoko-reason" :remote_host "remote_host" :lifetime_minutes 66} 13 | (InetAddress/getByName "www.name.de") 14 | '("allowed1" "allowed2")) 15 | => 16 | {:event_type 17 | {:name "request-ssh-access", 18 | :namespace "cloud.zalando.com", :version "1.1"}, 19 | :payload 20 | {:access_request_lifetime 66, 21 | :allowed_hostnames "allowed1,allowed2", 22 | :host_ip "213.160.69.3", 23 | :hostname "hostname", 24 | :reason "schoko-reason", 25 | :remote_host "remote_host"}, 26 | :triggered_at .date., 27 | :triggered_by 28 | {:id "authUserName", 29 | :type "EMPLOYEE_USERNAME"}} 30 | (provided 31 | (audit/get-date) => .date.)) 32 | 33 | (fact "creates valid audittrail event with only needed data leaving out everything else" 34 | (audit/create-event 35 | {:username "authUserName"} 36 | {:hostname "hostname" :lifetime_minutes 66} 37 | (InetAddress/getByName "www.name.de") 38 | '("allowed1" "allowed2")) 39 | => 40 | {:event_type 41 | {:name "request-ssh-access", 42 | :namespace "cloud.zalando.com", :version "1.1"}, 43 | :payload 44 | {:access_request_lifetime 66, 45 | :allowed_hostnames "allowed1,allowed2", 46 | :host_ip "213.160.69.3", 47 | :hostname "hostname"}, 48 | :triggered_at .date., 49 | :triggered_by 50 | {:id "authUserName", 51 | :type "EMPLOYEE_USERNAME"}} 52 | (provided 53 | (audit/get-date) => .date.)))) 54 | 55 | -------------------------------------------------------------------------------- /test/org/zalando/stups/even/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.core-test 2 | (:require 3 | [clojure.test :refer :all] 4 | [org.zalando.stups.even.core :refer :all])) 5 | 6 | (deftest test-system-map 7 | (is (map? (new-system {})))) 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/org/zalando/stups/even/job_test.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.job-test 2 | (:require [clojure.test :refer :all] 3 | [org.zalando.stups.even.job :refer :all] 4 | [org.zalando.stups.even.sql :as sql] 5 | [org.zalando.stups.even.ssh :as ssh])) 6 | 7 | 8 | (deftest test-get-revoke-ssh-options 9 | (is (= ["revoke-ssh-access" "myuser" "--remote-host" "myremote" "--keep-local"] (get-revoke-ssh-access-options "myremote" "myuser" 1)))) 10 | 11 | (deftest test-acquire-lock 12 | (with-redefs [sql/acquire-lock (constantly [{:l_id 123}])] 13 | (is (= {:id 123} (acquire-lock {}))))) 14 | 15 | (deftest test-acquire-lock-fail 16 | (with-redefs [sql/acquire-lock (fn [_ _] (throw (Exception. "duplicate key value violates unique constraint")))] 17 | (is (nil? (acquire-lock {}))))) 18 | 19 | (def example-access-request 20 | {:ar_created (java.sql.Timestamp. 123) 21 | :ar_lifetime_minutes 60}) 22 | 23 | (deftest test-revoke-access-requests 24 | (with-redefs [sql/get-expired-access-requests (constantly [example-access-request]) 25 | sql/update-access-request! (constantly 1) 26 | sql/count-remaining-granted-access-requests (constantly [{:count 0}]) 27 | ssh/execute-ssh (constantly {:exit 0})] 28 | 29 | (revoke-expired-access-requests {} {}))) 30 | 31 | (deftest test-revoke-access-requests-ssh-failure 32 | (with-redefs [sql/get-expired-access-requests (constantly [example-access-request]) 33 | sql/update-access-request! (constantly 1) 34 | sql/count-remaining-granted-access-requests (constantly [{:count 0}]) 35 | ssh/execute-ssh (constantly {:exit 1})] 36 | 37 | (revoke-expired-access-requests {} {}))) 38 | 39 | (deftest test-run-revoke-expired-access-requests 40 | (with-redefs [sql/clean-up-old-locks! (constantly 1) 41 | acquire-lock (constantly {:id 123}) 42 | revoke-expired-access-requests #(throw (Exception. (str "error" %1 %2))) 43 | sql/release-lock! (constantly nil)] 44 | (run-revoke-expired-access-requests {} {}))) 45 | 46 | (deftest test-retry-revocation-without-remote-host 47 | (are [res req] (= res (retry-revocation-without-remote-host? req)) 48 | true {:created (java.util.Date. 123) 49 | :lifetime_minutes 60 50 | :status "EXPIRED" 51 | :status_reason "Connection timed out"} 52 | false {:created (java.util.Date.) 53 | :lifetime_minutes 60 54 | :status "EXPIRED" 55 | :status_reason "Connection timed out"} 56 | false {:created (java.util.Date. 123) 57 | :lifetime_minutes 60 58 | :status "GRANTED"})) 59 | 60 | -------------------------------------------------------------------------------- /test/org/zalando/stups/even/sql_test.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.sql-test 2 | (:require [clojure.test :refer :all] 3 | [org.zalando.stups.even.job :refer :all] 4 | [org.zalando.stups.even.sql :as sql] 5 | [org.zalando.stups.even.ssh :as ssh])) 6 | 7 | (deftest test-from-sql 8 | (is (= {:foo_bar "hello"} (sql/from-sql {:tp_foo_bar "hello"})))) -------------------------------------------------------------------------------- /test/org/zalando/stups/even/ssh_test.clj: -------------------------------------------------------------------------------- 1 | (ns org.zalando.stups.even.ssh-test 2 | (:require 3 | [clojure.test :refer :all] 4 | [org.zalando.stups.even.ssh :refer :all] 5 | [clj-ssh.ssh :refer :all] 6 | [clojure.java.io :as io] 7 | [clojure.string :as str]) 8 | (:import (org.testcontainers.images.builder ImageFromDockerfile) 9 | (org.testcontainers.containers GenericContainer BindMode) 10 | (org.testcontainers.containers.wait HostPortWaitStrategy) 11 | (java.time Duration) 12 | (java.time.temporal ChronoUnit))) 13 | 14 | (defn load-key [key-id] 15 | (-> key-id io/resource slurp str/trim)) 16 | 17 | (def key-files ["key1.pem" "key2.pem"]) 18 | 19 | (def all-keys (str/join "\n" (map load-key key-files))) 20 | 21 | (defn test-with-pubkey 22 | [pub-key-file] 23 | (let [image (-> (ImageFromDockerfile.) 24 | (.withFileFromClasspath "Dockerfile", "dockerfile.sshd") 25 | (.withFileFromClasspath "entrypoint.sh", "entrypoint.sh")) 26 | container (doto (GenericContainer. image) 27 | (.addExposedPort (int 22)) 28 | (.addFileSystemBind pub-key-file "/authorized_keys" BindMode/READ_ONLY) 29 | (.setWaitStrategy (-> (HostPortWaitStrategy.) 30 | (.withStartupTimeout (Duration/of 60 ChronoUnit/SECONDS)))) 31 | (.start))] 32 | (try 33 | (is (= {:exit 0, :out "foobar", :err ""} 34 | (execute-ssh (.getContainerIpAddress container) 35 | "echo -n foobar" 36 | {:config {:user "root" 37 | :port (.getMappedPort container 22) 38 | :private-keys all-keys 39 | :private-key-password "Password" 40 | :agent-forwarding true 41 | :timeout 30}}))) 42 | (finally 43 | (.close container))))) 44 | 45 | (deftest test-execute-ssh 46 | (doseq [key key-files] 47 | (test-with-pubkey (format "dev-resources/%s.pub" key)))) 48 | 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | --------------------------------------------------------------------------------