1--- 2title: SSH and sudo 3kind: tutorial 4weight: 1 5--- 6 7Host-level access controls are an important part of every organization's 8security strategy. Using [Linux-PAM](http://tldp.org/HOWTO/User-Authentication-HOWTO/x115.html) and OPA 9we can extend policy-based access control to SSH and sudo. 10 11## Goals 12 13This tutorial shows how you can use OPA and Linux-PAM to enforce fine-grained, 14host-level access controls over SSH and sudo. 15 16Linux-PAM can be configured to delegate authorization decisions to plugins 17(shared libraries). In this case, we have created an OPA-based plugin that can 18be configured to authorize SSH and sudo access. The OPA-based Linux-PAM plugin 19used in this tutorial can be found at [open-policy-agent/contrib](https://github.com/open-policy-agent/contrib/tree/master/pam_opa). 20 21For this tutorial, our desired policy is: 22 23* Admins can SSH into any host and run sudo commands. 24* Normal users can SSH into hosts that they have *contributed* to and run sudo commands. 25 26Furthermore, we'll assume we have the following set of users and hosts: 27 28* `frontend-dev` is a developer who contributes to the app running on the `frontend` host. 29* `backend-dev` is a developer who contributes to the app running on the `backend` host. 30* `ops` is an administrator for the organization. 31 32Authentication (verifying user identity) is outside the scope of OPA's 33responsibility so this tutorial relies on identities being statically 34defined. In real-world scenarios authentication can be delegated to SSH itself 35(authorized_keys) or other identity management systems. 36 37Let's get started. 38 39## Prerequisites 40 41This tutorial requires [Docker Compose](https://docs.docker.com/compose/install/) to run dummy SSH hosts along 42with OPA. The dummy SSH hosts are just containers running sshd inside. 43 44## Steps 45 46### 1. Bootstrap the tutorial environment using Docker Compose. 47 48First, create a `tutorial-docker-compose.yaml` file that runs OPA and the containers that 49represent our backend and frontend hosts. 50 51**tutorial-docker-compose.yaml**: 52 53```yaml 54version: '2' 55services: 56 opa: 57 image: openpolicyagent/opa:{{< current_docker_version >}} 58 ports: 59 - 8181:8181 60 # WARNING: OPA is NOT running with an authorization policy configured. This 61 # means that clients can read and write policies in OPA. If you are 62 # deploying OPA in an insecure environment, be sure to configure 63 # authentication and authorization on the daemon. See the Security page for 64 # details: https://www.openpolicyagent.org/docs/security.html. 65 command: 66 - "run" 67 - "--server" 68 - "--set=decision_logs.console=true" 69 frontend: 70 image: openpolicyagent/demo-pam 71 ports: 72 - "2222:22" 73 volumes: 74 - ./frontend_host_id.json:/etc/host_identity.json 75 backend: 76 image: openpolicyagent/demo-pam 77 ports: 78 - "2223:22" 79 volumes: 80 - ./backend_host_id.json:/etc/host_identity.json 81``` 82 83The `tutorial-docker-compose.yaml` file requires two other local files: 84`frontend_host_id.json` and `backend_host_id.json`. These files are mounted 85into the containers representing our hosts. The content of the file provides 86*context* that the PAM module provides as input when executing queries 87against OPA. 88 89Create the extra files required by tutorial-docker-compose.yaml: 90 91```shell 92echo '{"host_id": "frontend"}' > frontend_host_id.json 93echo '{"host_id": "backend"}' > backend_host_id.json 94``` 95 96> In real-world scenarios, these files could contain arbitrary information that we want to expose to the policy. 97 98Finally, run `docker-compose` to pull and run the containers. 99 100```shell 101docker-compose -f tutorial-docker-compose.yaml up 102``` 103This tutorial uses a special Docker image named `openpolicyagent/demo-pam` to simulate an SSH server. 104This image contains pre-created Linux accounts for our users, and the required PAM module is 105pre-configured inside the `sudo` and `sshd` files in `/etc/pam.d/`. 106 107### 2. Load policies and data into OPA. 108 109In another terminal, load the policies and data into OPA that will control access to the hosts. 110 111First, create a policy that will tell the PAM module to collect context that is required for authorization. 112For more details on what this policy should look like, see 113[this documentation](https://github.com/open-policy-agent/contrib/tree/master/pam_authz/pam#pull). 114 115**pull.rego**: 116 117```live:ssh_pull:module:read_only 118package pull 119 120# Which files should be loaded into the context? 121files = ["/etc/host_identity.json"] 122 123# Which environment variables should be loaded into the context? 124env_vars = [] 125``` 126Load this policy into OPA. 127 128```shell 129curl -X PUT --data-binary @pull.rego \ 130 localhost:8181/v1/policies/pull 131``` 132 133Next, create the policies that will authorize SSH and sudo requests. 134The `input` which makes up the authorization context in the policy below will also 135include some default values, such as the username making the request. See 136[this documentation](https://github.com/open-policy-agent/contrib/tree/master/pam_authz/pam#authz) 137to get a better understanding of what the `input` to the authorization policy will look like. 138 139Unlike the *pull* policy, we'll create separate *authz* policies 140for SSH and `sudo` for more fine-grained control. 141In production, it makes more sense to have this separation for *display* and *pull* as well. 142 143Create the SSH authorization policy. It should allow admins to SSH into all hosts, 144and non-admins to only SSH into hosts that they contributed code to. 145 146**sshd_authz.rego**: 147 148```live:sshd_authz:module:read_only 149package sshd.authz 150 151import input.pull_responses 152import input.sysinfo 153 154import data.hosts 155 156# By default, users are not authorized. 157default allow = false 158 159# Allow access to any user that has the "admin" role. 160allow { 161 data.roles["admin"][_] == input.sysinfo.pam_username 162} 163 164# Allow access to any user who contributed to the code running on the host. 165# 166# This rule gets the "host_id" value from the file "/etc/host_identity.json". 167# It is available in the input under "pull_responses" because we 168# asked for it in our pull policy above. 169# 170# It then compares all the contributors for that host against the username 171# that is asking for authorization. 172allow { 173 hosts[pull_responses.files["/etc/host_identity.json"].host_id].contributors[_] == sysinfo.pam_username 174} 175 176# If the user is not authorized, then include an error message in the response. 177errors["Request denied by administrative policy"] { 178 not allow 179} 180``` 181 182Load this policy into OPA. 183 184```shell 185curl -X PUT --data-binary @sshd_authz.rego \ 186 localhost:8181/v1/policies/sshd/authz 187``` 188 189Create the `sudo` authorization policy. It should allow only admins to use `sudo`. 190 191**sudo_authz.rego**: 192 193```live:sudo_authz:module:read_only 194package sudo.authz 195 196# By default, users are not authorized. 197default allow = false 198 199# Allow access to any user that has the "admin" role. 200allow { 201 data.roles["admin"][_] == input.sysinfo.pam_username 202} 203 204# If the user is not authorized, then include an error message in the response. 205errors["Request denied by administrative policy"] { 206 not allow 207} 208``` 209 210Load this policy into OPA. 211 212```shell 213curl -X PUT --data-binary @sudo_authz.rego \ 214 localhost:8181/v1/policies/sudo/authz 215``` 216 217Finally, load the data that represents our roles and contributors into OPA. 218 219```shell 220curl -X PUT localhost:8181/v1/data/roles -d \ 221'{ 222 "admin": ["ops"] 223}' 224``` 225 226```shell 227curl -X PUT localhost:8181/v1/data/hosts -d \ 228'{ 229 "frontend": { 230 "contributors": [ 231 "frontend-dev" 232 ] 233 }, 234 "backend": { 235 "contributors": [ 236 "backend-dev" 237 ] 238 } 239}' 240``` 241 242### 3. SSH and sudo as a user with the `admin` role. 243 244First, let's try to access the hosts as the `ops` user. Recall, the `ops` user 245has been granted the `admin` role (via the `PUT /data/roles` request above) and 246users with the `admin` role can login to any host and perform sudo commands. 247 248Login to the `frontend` host (which has SSH listening on port 2222) and run a command with sudo as the `ops` user. 249 250```shell 251ssh -p 2222 ops@localhost \ 252 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 253 254sudo ls / 255exit 256``` 257 258You will see a lot of verbose logs from `sudo` as the PAM module goes through the motions. 259This is intended so you can study how the PAM module works. 260You can disable verbose logging by changing the `log_level` argument in the PAM 261configuration. For more details see 262[this documentation](https://github.com/open-policy-agent/contrib/tree/master/pam_authz/pam#configuration). 263 264### 4. SSH as a user without the `admin` role. 265 266Let's try a user without the admin role. Recall, that a non-admin user can SSH 267into any host that they have *contributed to*. 268 269The `frontend-dev` user contributed code to the `frontend` host so they should be 270able to login. 271 272```shell 273ssh -p 2222 frontend-dev@localhost \ 274 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 275``` 276 277Only admins can use `sudo`, so you shouldn't be able to run `sudo ls /`. 278 279Since `frontend-dev` did not contribute to the code running on the 280`backend` host (which has SSH listening on port 2223), they should not be able 281to login. 282 283```shell 284ssh -p 2223 frontend-dev@localhost \ 285 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 286``` 287 288### 5. Elevate a user's rights through policy. 289 290Suppose you have a ticketing system for elevation, where you generate tickets for users 291that need elevated rights, send the ticket to the user, and expire those tickets when 292their rights should be removed. 293 294Let's mock the current state of this simple ticketing system's API with some data. 295 296```shell 297curl -X PUT localhost:8181/v1/data/elevate -d \ 298'{ 299 "tickets": { 300 "frontend-dev": "1234" 301 } 302}' 303``` 304This means that for now, if the `frontend-dev` user can provide ticket number `1234`, 305they should be able to SSH into all servers. 306 307Let's write policy to ensure that this happens. 308 309First, we need to make the PAM module take input from the user. 310 311**display.rego**: 312 313```live:display:module:read_only 314package display 315 316# What should be prompted to the user? 317display_spec = [ 318 { 319 "message": "Please enter an elevation ticket if you have one:", 320 "style": "prompt_echo_on", 321 "key": "ticket" 322 } 323] 324``` 325 326Load this policy into OPA. 327 328```shell 329curl -X PUT --data-binary @display.rego \ 330 localhost:8181/v1/policies/display 331``` 332 333Then we need to make sure that the authorization takes this input into account. 334 335```live:sudo_authz/elevate:module:read_only 336# A package can be defined across multiple files. 337package sudo.authz 338 339import data.elevate 340import input.sysinfo 341import input.display_responses 342 343# Allow this user if the elevation ticket they provided matches our mock API 344# of an internal elevation system. 345allow { 346 elevate.tickets[sysinfo.pam_username] == display_responses.ticket 347} 348``` 349 350Load this policy into OPA. 351 352```shell 353curl -X PUT --data-binary @sudo_authz_elevated.rego \ 354 localhost:8181/v1/policies/sudo_authz_elevated 355``` 356 357Confirm that the user `frontend-dev` can indeed use `sudo`. 358 359```shell 360ssh -p 2222 frontend-dev@localhost \ 361 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 362 363sudo ls / 364``` 365 366You should be prompted with the message that we defined in our *display* policy 367for both the SSH and `sudo` authorization cycles. 368This happens because the *display* policy is shared by the PAM configurations of SSH and `sudo`. 369In production, it is more practical to use separate policy packages for each PAM configuration. 370 371We have not defined the SSH *authz* policy to work with elevation, so you can enter any value 372into the prompt that comes up for for SSH. 373 374For `sudo`, enter the ticket number `1234` to get access. 375 376Lastly, update the mocked elevation API and confirm the user's original rights are restored. 377 378```shell 379curl -X PUT localhost:8181/v1/data/elevate -d \ 380'{ 381 "tickets": {} 382}' 383``` 384 385You will find that running `sudo ls /` as the `frontend-dev` user is disallowed again. 386 387It is possible to configure the *display* policy to only make the PAM module prompt for the 388elevation ticket when our mock API has a non-empty `tickets` object. So when there are no 389elevated users, there will be no prompt for a ticket. This can be done using the Rego 390[`count` aggregate](http://www.openpolicyagent.org/docs/language-reference.html#aggregates). 391 392## Wrap Up 393 394Congratulations for finishing the tutorial! 395 396 You learned a number of things about SSH with OPA: 397 398* OPA gives you fine-grained access control over SSH, `sudo`, and any other application that uses PAM. 399 Although this tutorial used the some of the same policies for both 400 SSH and sudo, you should use separate, fine-grained policies for each application that supports PAM. 401* Writing allow/deny policies to control who has access to what using context from the user and host. 402* Importing external data into OPA and writing policies that depend on that data. 403 404The code for the PAM module used in this tutorial can be found in the 405[open-policy-agent/contrib](https://github.com/open-policy-agent/contrib) 406repository. 407