Host-level access controls are an important part of every organization’s security strategy. Using Linux-PAM and OPA we can extend policy-based access control to SSH and sudo.

Goals

This tutorial shows how you can use OPA and Linux-PAM to enforce fine-grained, host-level access controls over SSH and sudo.

Linux-PAM can be configured to delegate authorization decisions to plugins (shared libraries). In this case, we have created an OPA-based plugin that can be configured to authorize SSH and sudo access. The OPA-based Linux-PAM plugin used in this tutorial can be found at open-policy-agent/contrib.

For this tutorial, our desired policy is:

  • Admins can SSH into any host and run sudo commands.
  • Normal users can SSH into hosts that they have contributed to and run sudo commands.

Furthermore, we’ll assume we have the following set of users and hosts:

  • frontend-dev is a developer who contributes to the app running on the frontend host.
  • backend-dev is a developer who contributes to the app running on the backend host.
  • ops is an administrator for the organization.

Authentication (verifying user identity) is outside the scope of OPA’s responsibility so this tutorial relies on identities being statically defined. In real-world scenarios authentication can be delegated to SSH itself (authorized_keys) or other identity management systems.

Let’s get started.

Prerequisites

This tutorial requires Docker Compose to run dummy SSH hosts along with OPA. The dummy SSH hosts are just containers running sshd inside.

Steps

1. Bootstrap the tutorial environment using Docker Compose.

First, create a tutorial-docker-compose.yaml file that runs OPA and the containers that represent our backend and frontend hosts.

tutorial-docker-compose.yaml:

  1. version: '2'
  2. services:
  3. opa:
  4. image: openpolicyagent/opa:0.29.4
  5. ports:
  6. - 8181:8181
  7. # WARNING: OPA is NOT running with an authorization policy configured. This
  8. # means that clients can read and write policies in OPA. If you are
  9. # deploying OPA in an insecure environment, be sure to configure
  10. # authentication and authorization on the daemon. See the Security page for
  11. # details: https://www.openpolicyagent.org/docs/security.html.
  12. command:
  13. - "run"
  14. - "--server"
  15. - "--set=decision_logs.console=true"
  16. frontend:
  17. image: openpolicyagent/demo-pam
  18. ports:
  19. - "2222:22"
  20. volumes:
  21. - ./frontend_host_id.json:/etc/host_identity.json
  22. backend:
  23. image: openpolicyagent/demo-pam
  24. ports:
  25. - "2223:22"
  26. volumes:
  27. - ./backend_host_id.json:/etc/host_identity.json

The tutorial-docker-compose.yaml file requires two other local files: frontend_host_id.json and backend_host_id.json. These files are mounted into the containers representing our hosts. The content of the file provides context that the PAM module provides as input when executing queries against OPA.

Create the extra files required by tutorial-docker-compose.yaml:

  1. echo '{"host_id": "frontend"}' > frontend_host_id.json
  2. echo '{"host_id": "backend"}' > backend_host_id.json

In real-world scenarios, these files could contain arbitrary information that we want to expose to the policy.

Finally, run docker-compose to pull and run the containers.

  1. docker-compose -f tutorial-docker-compose.yaml up

This tutorial uses a special Docker image named openpolicyagent/demo-pam to simulate an SSH server. This image contains pre-created Linux accounts for our users, and the required PAM module is pre-configured inside the sudo and sshd files in /etc/pam.d/.

2. Load policies and data into OPA.

In another terminal, load the policies and data into OPA that will control access to the hosts.

First, create a policy that will tell the PAM module to collect context that is required for authorization. For more details on what this policy should look like, see this documentation.

pull.rego:

  1. package pull
  2. # Which files should be loaded into the context?
  3. files = ["/etc/host_identity.json"]
  4. # Which environment variables should be loaded into the context?
  5. env_vars = []

Load this policy into OPA.

  1. curl -X PUT --data-binary @pull.rego \
  2. localhost:8181/v1/policies/pull

Next, create the policies that will authorize SSH and sudo requests. The input which makes up the authorization context in the policy below will also include some default values, such as the username making the request. See this documentation to get a better understanding of what the input to the authorization policy will look like.

Unlike the pull policy, we’ll create separate authz policies for SSH and sudo for more fine-grained control. In production, it makes more sense to have this separation for display and pull as well.

Create the SSH authorization policy. It should allow admins to SSH into all hosts, and non-admins to only SSH into hosts that they contributed code to.

sshd_authz.rego:

  1. package sshd.authz
  2. import input.pull_responses
  3. import input.sysinfo
  4. import data.hosts
  5. # By default, users are not authorized.
  6. default allow = false
  7. # Allow access to any user that has the "admin" role.
  8. allow {
  9. data.roles["admin"][_] == input.sysinfo.pam_username
  10. }
  11. # Allow access to any user who contributed to the code running on the host.
  12. #
  13. # This rule gets the "host_id" value from the file "/etc/host_identity.json".
  14. # It is available in the input under "pull_responses" because we
  15. # asked for it in our pull policy above.
  16. #
  17. # It then compares all the contributors for that host against the username
  18. # that is asking for authorization.
  19. allow {
  20. hosts[pull_responses.files["/etc/host_identity.json"].host_id].contributors[_] == sysinfo.pam_username
  21. }
  22. # If the user is not authorized, then include an error message in the response.
  23. errors["Request denied by administrative policy"] {
  24. not allow
  25. }

Load this policy into OPA.

  1. curl -X PUT --data-binary @sshd_authz.rego \
  2. localhost:8181/v1/policies/sshd/authz

Create the sudo authorization policy. It should allow only admins to use sudo.

sudo_authz.rego:

  1. package sudo.authz
  2. # By default, users are not authorized.
  3. default allow = false
  4. # Allow access to any user that has the "admin" role.
  5. allow {
  6. data.roles["admin"][_] == input.sysinfo.pam_username
  7. }
  8. # If the user is not authorized, then include an error message in the response.
  9. errors["Request denied by administrative policy"] {
  10. not allow
  11. }

Load this policy into OPA.

  1. curl -X PUT --data-binary @sudo_authz.rego \
  2. localhost:8181/v1/policies/sudo/authz

Finally, load the data that represents our roles and contributors into OPA.

  1. curl -X PUT localhost:8181/v1/data/roles -d \
  2. '{
  3. "admin": ["ops"]
  4. }'
  1. curl -X PUT localhost:8181/v1/data/hosts -d \
  2. '{
  3. "frontend": {
  4. "contributors": [
  5. "frontend-dev"
  6. ]
  7. },
  8. "backend": {
  9. "contributors": [
  10. "backend-dev"
  11. ]
  12. }
  13. }'

3. SSH and sudo as a user with the admin role.

First, let’s try to access the hosts as the ops user. Recall, the ops user has been granted the admin role (via the PUT /data/roles request above) and users with the admin role can login to any host and perform sudo commands.

Login to the frontend host (which has SSH listening on port 2222) and run a command with sudo as the ops user.

  1. ssh -p 2222 ops@localhost \
  2. -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
  3. sudo ls /
  4. exit

You will see a lot of verbose logs from sudo as the PAM module goes through the motions. This is intended so you can study how the PAM module works. You can disable verbose logging by changing the log_level argument in the PAM configuration. For more details see this documentation.

4. SSH as a user without the admin role.

Let’s try a user without the admin role. Recall, that a non-admin user can SSH into any host that they have contributed to.

The frontend-dev user contributed code to the frontend host so they should be able to login.

  1. ssh -p 2222 frontend-dev@localhost \
  2. -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null

Only admins can use sudo, so you shouldn’t be able to run sudo ls /.

Since frontend-dev did not contribute to the code running on the backend host (which has SSH listening on port 2223), they should not be able to login.

  1. ssh -p 2223 frontend-dev@localhost \
  2. -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null

5. Elevate a user’s rights through policy.

Suppose you have a ticketing system for elevation, where you generate tickets for users that need elevated rights, send the ticket to the user, and expire those tickets when their rights should be removed.

Let’s mock the current state of this simple ticketing system’s API with some data.

  1. curl -X PUT localhost:8181/v1/data/elevate -d \
  2. '{
  3. "tickets": {
  4. "frontend-dev": "1234"
  5. }
  6. }'

This means that for now, if the frontend-dev user can provide ticket number 1234, they should be able to SSH into all servers.

Let’s write policy to ensure that this happens.

First, we need to make the PAM module take input from the user.

display.rego:

  1. package display
  2. # What should be prompted to the user?
  3. display_spec = [
  4. {
  5. "message": "Please enter an elevation ticket if you have one:",
  6. "style": "prompt_echo_on",
  7. "key": "ticket"
  8. }
  9. ]

Load this policy into OPA.

  1. curl -X PUT --data-binary @display.rego \
  2. localhost:8181/v1/policies/display

Then we need to make sure that the authorization takes this input into account.

  1. # A package can be defined across multiple files.
  2. package sudo.authz
  3. import data.elevate
  4. import input.sysinfo
  5. import input.display_responses
  6. # Allow this user if the elevation ticket they provided matches our mock API
  7. # of an internal elevation system.
  8. allow {
  9. elevate.tickets[sysinfo.pam_username] == display_responses.ticket
  10. }

Load this policy into OPA.

  1. curl -X PUT --data-binary @sudo_authz_elevated.rego \
  2. localhost:8181/v1/policies/sudo_authz_elevated

Confirm that the user frontend-dev can indeed use sudo.

  1. ssh -p 2222 frontend-dev@localhost \
  2. -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
  3. sudo ls /

You should be prompted with the message that we defined in our display policy for both the SSH and sudo authorization cycles. This happens because the display policy is shared by the PAM configurations of SSH and sudo. In production, it is more practical to use separate policy packages for each PAM configuration.

We have not defined the SSH authz policy to work with elevation, so you can enter any value into the prompt that comes up for for SSH.

For sudo, enter the ticket number 1234 to get access.

Lastly, update the mocked elevation API and confirm the user’s original rights are restored.

  1. curl -X PUT localhost:8181/v1/data/elevate -d \
  2. '{
  3. "tickets": {}
  4. }'

You will find that running sudo ls / as the frontend-dev user is disallowed again.

It is possible to configure the display policy to only make the PAM module prompt for the elevation ticket when our mock API has a non-empty tickets object. So when there are no elevated users, there will be no prompt for a ticket. This can be done using the Rego count aggregate.

Wrap Up

Congratulations for finishing the tutorial!

You learned a number of things about SSH with OPA:

  • OPA gives you fine-grained access control over SSH, sudo, and any other application that uses PAM. Although this tutorial used the some of the same policies for both SSH and sudo, you should use separate, fine-grained policies for each application that supports PAM.
  • Writing allow/deny policies to control who has access to what using context from the user and host.
  • Importing external data into OPA and writing policies that depend on that data.

The code for the PAM module used in this tutorial can be found in the open-policy-agent/contrib repository.