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