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.