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