Integrate SSH with Open Policy Agent

Anthony Critelli
10 min readOct 10, 2023
OPA and OpenSSH logos on dark background

Open Policy Agent (OPA) is a powerful tool for expressing and evaluating policy in cloud environments. While commonly used in for Kubernetes admission control and policy evaluation for applications, OPA can be used effectively across a variety of domains that require policy enforcement. In this article, I’ll take you through a simple example that leverages OPA and OpenSSH to provide SSH authentication decisions.

The official OPA documentation has an excellent example of using Linux’s Pluggable Authentication Modules (PAM) to authorize SSH connections using the project’s official module. This is a robust implementation, but there are scenarios where you may not want to modify PAM, either due to organizational restrictions or in an effort to avoid introducing additional dependencies.

OpenSSH provides an AuthorizedKeysCommand functionality that allows a custom command to run and return a list of SSH public keys for a user. This custom command can implement arbitrary logic to authenticate an SSH connection. The only requirement is that it prints out “authorized_keys” output to standard output. This “authorized_keys” output is in the same format as an SSH authorized keys file that is typically found at ~/.ssh/authorized_keys .

Paring OPA with the AuthorizedKeysCommand functionality in OpenSSH allows you to make a decision about an SSH connection without any additional dependencies. Using only OPA and a simple script with common Linux utilities, such as jq , you can implement a robust authentication mechanism for OpenSSH without any additional dependencies.

A Basic Policy Bundle

OPA operates on policies, which are expressed in the Rego query language. A basic policy for an OpenSSH AuthorizedKeysCommand must implement the following logic:

  1. Accept a username
  2. Determine if the user is allowed to connect via SSH
  3. If the user is allowed to connect, then print out one or more SSH public keys for the user

I’ll start by implementing this logic with a simple policy and a local file containing a user’s SSH keys. This is just the foundation: more complex policy decisions are possible, as long as the above logic is generally followed. Later, I’ll show how I can also pull SSH keys from an upstream location, such as GitHub, directly in the policy.

First, I’ll create a basic directory structure for a policy bundle.

# Create and change to a directory for the policy bundle
mkdir -p /etc/opa-ssh/bundles/local_keys
cd /etc/opa-ssh/bundles

# Create the main policy file and a data file
touch main.rego local_keys/data.json

Next, I’ll create the data for the policy at local_keys/data.json . I have two users on the system: anthony and megan . Each has some public keys:

{
"anthony": [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDIBnvo20798GtKcofu2ukpdvwK1saXGaYAH45BX/Z1zePsmdp+g1GSmLPJU6YAlGHRr2v214u0rbZFYaQAO77NnEmgRgfax1zPfTYrxvH0rL9SEIUDNFY1MivfxGlJ14mEDuZWkOUl2M02aAyMLopwdke7xHS2weFnJ24JSS8VJvHY65pDLZD2AmBkSmmU0R6wUXz2bWDOInN5QKVepXP94k2l/gbtwZLf7nTCK6QsOnmumtLFYYdUIWn/R5v7MP0fDy7wVsn21cBu3RHBL39jA8HLFuCLGzjYBrvb+Guce3Po0hvP4b3TQNOzwMIRRFM+wLDCR07YeWOFHk0RjjVtl2rrqQdeEQzyiR+CdPe7NduY+wvza7k351SXhtJUOeU1cMeUzI74MbRr2JOMgvFEIeaI3uANv4A9D9T4nUlPioWwuS8ahrdy8mLssx9XxMLtGQD1FiyKpZ2l93Bsl1d9Q98bWEtibkYW76GFf+Pvk0Pg74A5Ey7SMq3C4UdBLJM=",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDFwxsBt4DU0aN0kR524BHddYNDSUpdrB/9Syqvr+gBkn2QLRlIrPLG6i+HaYzDr5hHpB7KXYdyvgLw4Ikz8eDhB6AyIZZ4u4RyG34RBdS2qpQxJN4jJmz8EymSps13S7IdF7TjhmL8vEIuy6nmneDpze7je7jWmbERcIPB1dd60xapsv1P3r7SRdGmWmuWCZ6mFCQc2YzLDRmZX/Q9qLptU78HNd2u1tFRk+MU8hg5jpOtMokdzEaMRRfXWt0id4bf8/S6Mxm0Rcxhsmb5AnfHGnpvyXclCWo39S9ab7jgjFMv45liGS7lZHVdQPDU0dUl3Kh0JPOKqSLUPirpgF7SZFzD/8dsAuVACs2Om120R631CrqQGAHpgJe273JSw8kl8d/wkN7+/HGlyHZKaYYF1yzARc4CCQYDg8njPjrjOWQI8RXh/VF7WR+qbrkOfDqVpwnkzx5+2NcgVbrXa7vEKJTMb8GtWPwTKAlPIBDDTZ6AzTjRgjz8pTyos9KbXKM="
],
"megan": [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDAlxR333XuOI2uclXczprSrDTla6nNT2fstfnMETQB5s+cA1TBzEtN4NyBQS/Ve3uNHZXeWnRDtWSmrRkBQl0fM6BetdS8p10cK2zpisSs9NT7pMAd8DknW4KVmplDQlH/YGW2ZEyEItvzHxZyv0JkrOFQH11TXjJxYs5CcHooVgDvjAx0AQLYnYFYvZQfMY/CnFMHEpUKpvNwhJyG9Vv3+D5OL0Z5UKHC5tQIPb+HlkCcldOFsZLQ90Itskw3iy44EBlLQLCPm349Y9Dm1ObtZunSGVoz9D1MZX3l+DHtKqLKaLFCWraIL/emISb+pLkLsnZf1VoVlTUDulGevb+n/+5slGDF6fVBZJDfq5RPz0DUpon1WBZGKrkzoHHaJHmo1MiQRjbNZc9rHAA22Sa2VSdgCixfX4Yn3S2bV7+7JPI0qr01znjiZjacm2HKm6A1lOSJvp3s7sPADjCXCQut7OP8xzLf7GvwR2XPRQqQL/ijaDP9loLRqKEnPU52NkU=",
]
}

Next, I’ll implement a basic policy. This policy defines an allow rule that evaluates to true if the local_keys rule is defined. Otherwise, allow defaults to false . In other words: if there are local SSH keys for the user, then local_keys will be defined as a list of those keys, and allow will subsequently evaluate to true .

The local_keys rule evaluates to a list of keys for a user from the data.keys object. This is the JSON data structure that I created at local_keys/data.json .

package ssh.keys

import future.keywords.if

default allow := false

allow if {
local_ssh_keys
}

local_ssh_keys := keys if {
keys := data.local_keys[input.user]
}

This policy assumes there is a user field in the input object. To test this, I’ll create some test JSON data in a file:

echo '{ "user": "anthony" }' > /tmp/input.json  

Now I can test out the policy using opa exec . I evaluate the entire ssh/keys decision, as this gives me the values for all of the rules. The local_ssh_keys rule evaluates to an array of the user’s SSH keys. Since this rule is defined, the allow rule also evaluates to true :

opa exec --bundle /etc/opa-ssh/bundles/ --decision ssh/keys /tmp/input.json
{
"result": [
{
"path": "/tmp/input.json",
"result": {
"allow": true,
"local_ssh_keys": [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDIBnvo20798GtKcofu2ukpdvwK1saXGaYAH45BX/Z1zePsmdp+g1GSmLPJU6YAlGHRr2v214u0rbZFYaQAO77NnEmgRgfax1zPfTYrxvH0rL9SEIUDNFY1MivfxGlJ14mEDuZWkOUl2M02aAyMLopwdke7xHS2weFnJ24JSS8VJvHY65pDLZD2AmBkSmmU0R6wUXz2bWDOInN5QKVepXP94k2l/gbtwZLf7nTCK6QsOnmumtLFYYdUIWn/R5v7MP0fDy7wVsn21cBu3RHBL39jA8HLFuCLGzjYBrvb+Guce3Po0hvP4b3TQNOzwMIRRFM+wLDCR07YeWOFHk0RjjVtl2rrqQdeEQzyiR+CdPe7NduY+wvza7k351SXhtJUOeU1cMeUzI74MbRr2JOMgvFEIeaI3uANv4A9D9T4nUlPioWwuS8ahrdy8mLssx9XxMLtGQD1FiyKpZ2l93Bsl1d9Q98bWEtibkYW76GFf+Pvk0Pg74A5Ey7SMq3C4UdBLJM=",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDFwxsBt4DU0aN0kR524BHddYNDSUpdrB/9Syqvr+gBkn2QLRlIrPLG6i+HaYzDr5hHpB7KXYdyvgLw4Ikz8eDhB6AyIZZ4u4RyG34RBdS2qpQxJN4jJmz8EymSps13S7IdF7TjhmL8vEIuy6nmneDpze7je7jWmbERcIPB1dd60xapsv1P3r7SRdGmWmuWCZ6mFCQc2YzLDRmZX/Q9qLptU78HNd2u1tFRk+MU8hg5jpOtMokdzEaMRRfXWt0id4bf8/S6Mxm0Rcxhsmb5AnfHGnpvyXclCWo39S9ab7jgjFMv45liGS7lZHVdQPDU0dUl3Kh0JPOKqSLUPirpgF7SZFzD/8dsAuVACs2Om120R631CrqQGAHpgJe273JSw8kl8d/wkN7+/HGlyHZKaYYF1yzARc4CCQYDg8njPjrjOWQI8RXh/VF7WR+qbrkOfDqVpwnkzx5+2NcgVbrXa7vEKJTMb8GtWPwTKAlPIBDDTZ6AzTjRgjz8pTyos9KbXKM="
]
}
}
]
}

Next, I can test the rule for a user who does not have any SSH keys (and presumably shouldn’t be allowed to log into the system). First, I’ll create some test input:

echo '{ "user": "trudy" }' > /tmp/input.json

This time, the local_ssh_keys rule is undefined and OPA doesn’t even include it in the result . Since the local_ssh_keys rule is undefined, the allow rule evaluates to its default value of false :

opa exec --bundle /etc/opa-ssh/bundles/ --decision ssh/keys /tmp/input.json
{
"result": [
{
"path": "/tmp/input.json",
"result": {
"allow": false
}
}
]
}

Building an AuthorizedKeysCommand

Now that I have a policy, it’s time to wrap it in a command that OpenSSH can execute. An AuthorizedKeysCommand can be any executable, including a bash script. The command’s purpose is very simple: it must print “authorized_keys” output to standard output. OpenSSH can optionally provide certain input arguments, such as the username, to the executable. The full list of arguments is documented in the TOKENS section of the documentation. In this case, I need a script that implements the following logic:

  1. Accept a username from OpenSSH and write it to an input file in JSON format
  2. Execute the policy against the input file
  3. If the policy allows the user, then print the user’s SSH keys to standard output
  4. If the policy does not allow the user, then simply exit with a non-0 exit code

First, I’ll create a script. OpenSSH is strict on its requirements for AuthorizedKeysCommand executables: they must be owned by root and not writeable by group or others.

# Create the script
touch /usr/local/sbin/opa-authorized-keys.sh

# Ensure it's owned by root
chown root:root /usr/local/sbin/opa-authorized-keys.sh

# Set permissions. Others must have executable permissions
## for reasons that will be explained shortly
chmod 0755 /usr/local/sbin/opa-authorized-keys.sh

Next, I’ll write a script to implement my desired logic. The script ses jq to process the JSON output from OPA. I’ve added some niceties, such as logging and exit handling, to make the script more robust:

#!/usr/bin/env bash

# Write standard error to a log file
## Anytime you see ">&2" in the script, it will go to the log file
exec 2>> /var/log/opa-auth-keys.log

echo "Looking up keys for $1" >&2

# Create a temporary file to store the OPA input
# OPA input files MUST end in .json, otherwise they won't be read
TEMPFILE=$(mktemp --suffix ".json")
echo "Using input file $TEMPFILE" >&2

# Ensure the temporary input file is cleaned up on exit
trap "rm $TEMPFILE" exit

# Write the first CLI argument (the username) to the temporary file
echo "{ \"user\": \"$1\" }" > "$TEMPFILE"

# Execute OPA against the input file and store the results in a variable
RESULT=$(opa exec \
--bundle /etc/opa-ssh/bundles/ \
--decision ssh/keys "$TEMPFILE")

# Process the output from OPA to determine if the user is allowed to SSH
## If not, log a message and exit non-0
ALLOWED=$(echo $RESULT | jq -r '.result[0].result.allow')
if [ $ALLOWED == "false" ]
then
echo 'OPA evaluation for "allow" was false' >&2
exit 1
fi

# If the user was allowed, then obtain their keys and print them to
## standard output by combining each array element with a newline
echo $RESULT | jq -r '.result[0].result.local_ssh_keys | join("\n")'

I can test this script by calling it directly. If a user has SSH keys in the local_keys data structure, then they will be printed to standard output. Otherwise, the script will exit with a non-0 exit code, which will cause OpenSSH to reject the authentication attempt:

# A user with SSH keys will exit 0 and print the keys to standard output
$ /usr/local/sbin/opa-authorized-keys.sh anthony
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDIBnvo20798GtKcofu2ukpdvwK1saXGaYAH45BX/Z1zePsmdp+g1GSmLPJU6YAlGHRr2v214u0rbZFYaQAO77NnEmgRgfax1zPfTYrxvH0rL9SEIUDNFY1MivfxGlJ14mEDuZWkOUl2M02aAyMLopwdke7xHS2weFnJ24JSS8VJvHY65pDLZD2AmBkSmmU0R6wUXz2bWDOInN5QKVepXP94k2l/gbtwZLf7nTCK6QsOnmumtLFYYdUIWn/R5v7MP0fDy7wVsn21cBu3RHBL39jA8HLFuCLGzjYBrvb+Guce3Po0hvP4b3TQNOzwMIRRFM+wLDCR07YeWOFHk0RjjVtl2rrqQdeEQzyiR+CdPe7NduY+wvza7k351SXhtJUOeU1cMeUzI74MbRr2JOMgvFEIeaI3uANv4A9D9T4nUlPioWwuS8ahrdy8mLssx9XxMLtGQD1FiyKpZ2l93Bsl1d9Q98bWEtibkYW76GFf+Pvk0Pg74A5Ey7SMq3C4UdBLJM=
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDFwxsBt4DU0aN0kR524BHddYNDSUpdrB/9Syqvr+gBkn2QLRlIrPLG6i+HaYzDr5hHpB7KXYdyvgLw4Ikz8eDhB6AyIZZ4u4RyG34RBdS2qpQxJN4jJmz8EymSps13S7IdF7TjhmL8vEIuy6nmneDpze7je7jWmbERcIPB1dd60xapsv1P3r7SRdGmWmuWCZ6mFCQc2YzLDRmZX/Q9qLptU78HNd2u1tFRk+MU8hg5jpOtMokdzEaMRRfXWt0id4bf8/S6Mxm0Rcxhsmb5AnfHGnpvyXclCWo39S9ab7jgjFMv45liGS7lZHVdQPDU0dUl3Kh0JPOKqSLUPirpgF7SZFzD/8dsAuVACs2Om120R631CrqQGAHpgJe273JSw8kl8d/wkN7+/HGlyHZKaYYF1yzARc4CCQYDg8njPjrjOWQI8RXh/VF7WR+qbrkOfDqVpwnkzx5+2NcgVbrXa7vEKJTMb8GtWPwTKAlPIBDDTZ6AzTjRgjz8pTyos9KbXKM=
$ echo $?
0

# A user without SSH keys will cause the script to exit non-0
$ /usr/local/sbin/opa-authorized-keys.sh trudy
$ echo $?
1

Next, it’s just a matter of plugging this script into OpenSSH. OpenSSH recommends running AuthorizedKeysCommand executables as a separate user on the system, so I’ve created an sshd-keys user. The following two lines in /etc/ssh/sshd_config tell OpenSSH to use my script and pass the username as the first script argument:

AuthorizedKeysCommand /usr/local/sbin/opa-authorized-keys.sh %u
AuthorizedKeysCommandUser sshd-keys

One additional step is needed before I can restart OpenSSH and start using my new configuration. The script writes to a file at /var/log/opa-auth-keys.log . The parent directory isn’t writable by regular users. Since the script will execute as the sshd-keys user, I need to create this log file and adjust its permissions so the user can write to it. Otherwise, I won’t get any log messages:

touch /var/log/opa-auth-keys.log
chown sshd-keys:sshd-keys /var/log/opa-auth-keys.log

Finally, I can restart OpenSSH:

systemctl restart sshd

With all of this configuration in place, I can attempt to SSH into the host:

# A successful authentication attempt for a user with keys
$ ssh anthony@192.168.100.10
Welcome to Ubuntu 22.04.2 LTS (GNU/Linux 5.15.0-86-generic x86_64)

# A failed authentication attempt
$ ssh trudy@192.168.100.10
trudy@192.168.100.10: Permission denied (publickey).

Inspecting the log file shows the script working as designed. Note that the command is called twice. I haven’t quite figured out why OpenSSH does this yet.

$ tail -f /var/log/opa-auth-keys.log
Looking up keys for anthony
Using input file /tmp/tmp.Udt5cbuflW.json
Looking up keys for anthony
Using input file /tmp/tmp.lgTcfR5EVq.json

You’ll also notice that there is no log entry for trudy . This is because trudy doesn’t even exist on the system. OpenSSH won’t even call the command if the user doesn’t exist.

However, I would like to test my command against a user that exists on the system but doesn’t have any public keys (and so isn’t authorized to SSH to the system). To do that, I can simply add the trudy user and test again:

# Result after adding the trudy user and attempting to SSH
$ tail -n 3 /var/log/opa-auth-keys.log
Looking up keys for trudy
Using input file /tmp/tmp.8huZ2YPpHL.json
OPA evaluation for "allow" was false

Astute readers will notice that my script doesn’t do any sanitization on the username argument when writing the JSON file. Therefore, the script itself is vulnerable to injection if a malformed username contains JSON data. This isn’t really a concern for this use case: OpenSSH will only pass valid users as arguments to the script. However, I recommend implementing more robust input sanitization in a production environment.

Extending the Policy with Remote SSH Keys

So far, I have an interesting but relatively toy use case. This same logic could just as easily be accomplished via standard SSH key files on the system, without the overhead of OPA. However, the work here lays a foundation for more complex policy evaluation. For example, my policy could:

  1. Make more complex decisions about a user. Maybe a particular user should only be allowed to SSH during certain hours of the day. I am only limited by my imagination and the capabilities of OPA.
  2. Ingest data, including the SSH keys themselves, from external systems for use in the policy decision process.

I’ll demonstrate the second scenario here. It’s common for users to store their SSH public keys on GitHub or a source code management platform. For example, mine can be found on GitHub. I can extend the policy to look up both a user’s local keys and their remote keys. To do this, I need some type of mapping between a local system username and the location of their remote keys. I can implement this in the same way that I implemented local SSH keys: with data.

First, I’ll create a new directory and JSON file to hold the data:

mkdir /etc/opa-ssh/bundles/remote_keys
touch /etc/opa-ssh/bundles/remote_keys/data.json

Next, I’ll create a simple JSON data structure in /etc/opa-ssh/bundles/remote_keys/data.json to map a local username to a remote location for SSH key lookup:

{
"anthony": "https://github.com/acritelli.keys"
}

The updates to the policy are simple. I create a new remote_ssh_keys rule. This rule looks up the URL for the user, sends an HTTP request, and then extracts any keys from the response body. The allow rule will now evaluate to true if there are any remote SSH keys

Note that the following changes are additions to the previous policy. By defining the allow rule multiple times, I can express a logical OR. The allow rule evaluates to true if there are any local keys OR remote keys.

allow if {
remote_ssh_keys
}

remote_ssh_keys := keys if {
url := data.remote_keys[input.user]
response := http.send({
"url": url,
"method": "GET",
})

# Trim the trailing newline from the response body string
# And then split the string into an array of keys based on newline
keys := split(trim_right(response.raw_body, "\n"), "\n")
}

Executing OPA against this policy now yields both local and remote SSH keys, if any exist. In my case, these are identical because my remote GitHub keys are the same as the local keys I’ve defined in data.local_keys :

$ opa exec --bundle /etc/opa-ssh/bundles/ --decision ssh/keys /tmp/input.json
{
"result": [
{
"path": "/tmp/input.json",
"result": {
"allow": true,
"local_ssh_keys": [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDIBnvo20798GtKcofu2ukpdvwK1saXGaYAH45BX/Z1zePsmdp+g1GSmLPJU6YAlGHRr2v214u0rbZFYaQAO77NnEmgRgfax1zPfTYrxvH0rL9SEIUDNFY1MivfxGlJ14mEDuZWkOUl2M02aAyMLopwdke7xHS2weFnJ24JSS8VJvHY65pDLZD2AmBkSmmU0R6wUXz2bWDOInN5QKVepXP94k2l/gbtwZLf7nTCK6QsOnmumtLFYYdUIWn/R5v7MP0fDy7wVsn21cBu3RHBL39jA8HLFuCLGzjYBrvb+Guce3Po0hvP4b3TQNOzwMIRRFM+wLDCR07YeWOFHk0RjjVtl2rrqQdeEQzyiR+CdPe7NduY+wvza7k351SXhtJUOeU1cMeUzI74MbRr2JOMgvFEIeaI3uANv4A9D9T4nUlPioWwuS8ahrdy8mLssx9XxMLtGQD1FiyKpZ2l93Bsl1d9Q98bWEtibkYW76GFf+Pvk0Pg74A5Ey7SMq3C4UdBLJM=",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDFwxsBt4DU0aN0kR524BHddYNDSUpdrB/9Syqvr+gBkn2QLRlIrPLG6i+HaYzDr5hHpB7KXYdyvgLw4Ikz8eDhB6AyIZZ4u4RyG34RBdS2qpQxJN4jJmz8EymSps13S7IdF7TjhmL8vEIuy6nmneDpze7je7jWmbERcIPB1dd60xapsv1P3r7SRdGmWmuWCZ6mFCQc2YzLDRmZX/Q9qLptU78HNd2u1tFRk+MU8hg5jpOtMokdzEaMRRfXWt0id4bf8/S6Mxm0Rcxhsmb5AnfHGnpvyXclCWo39S9ab7jgjFMv45liGS7lZHVdQPDU0dUl3Kh0JPOKqSLUPirpgF7SZFzD/8dsAuVACs2Om120R631CrqQGAHpgJe273JSw8kl8d/wkN7+/HGlyHZKaYYF1yzARc4CCQYDg8njPjrjOWQI8RXh/VF7WR+qbrkOfDqVpwnkzx5+2NcgVbrXa7vEKJTMb8GtWPwTKAlPIBDDTZ6AzTjRgjz8pTyos9KbXKM="
],
"remote_ssh_keys": [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDIBnvo20798GtKcofu2ukpdvwK1saXGaYAH45BX/Z1zePsmdp+g1GSmLPJU6YAlGHRr2v214u0rbZFYaQAO77NnEmgRgfax1zPfTYrxvH0rL9SEIUDNFY1MivfxGlJ14mEDuZWkOUl2M02aAyMLopwdke7xHS2weFnJ24JSS8VJvHY65pDLZD2AmBkSmmU0R6wUXz2bWDOInN5QKVepXP94k2l/gbtwZLf7nTCK6QsOnmumtLFYYdUIWn/R5v7MP0fDy7wVsn21cBu3RHBL39jA8HLFuCLGzjYBrvb+Guce3Po0hvP4b3TQNOzwMIRRFM+wLDCR07YeWOFHk0RjjVtl2rrqQdeEQzyiR+CdPe7NduY+wvza7k351SXhtJUOeU1cMeUzI74MbRr2JOMgvFEIeaI3uANv4A9D9T4nUlPioWwuS8ahrdy8mLssx9XxMLtGQD1FiyKpZ2l93Bsl1d9Q98bWEtibkYW76GFf+Pvk0Pg74A5Ey7SMq3C4UdBLJM=",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDFwxsBt4DU0aN0kR524BHddYNDSUpdrB/9Syqvr+gBkn2QLRlIrPLG6i+HaYzDr5hHpB7KXYdyvgLw4Ikz8eDhB6AyIZZ4u4RyG34RBdS2qpQxJN4jJmz8EymSps13S7IdF7TjhmL8vEIuy6nmneDpze7je7jWmbERcIPB1dd60xapsv1P3r7SRdGmWmuWCZ6mFCQc2YzLDRmZX/Q9qLptU78HNd2u1tFRk+MU8hg5jpOtMokdzEaMRRfXWt0id4bf8/S6Mxm0Rcxhsmb5AnfHGnpvyXclCWo39S9ab7jgjFMv45liGS7lZHVdQPDU0dUl3Kh0JPOKqSLUPirpgF7SZFzD/8dsAuVACs2Om120R631CrqQGAHpgJe273JSw8kl8d/wkN7+/HGlyHZKaYYF1yzARc4CCQYDg8njPjrjOWQI8RXh/VF7WR+qbrkOfDqVpwnkzx5+2NcgVbrXa7vEKJTMb8GtWPwTKAlPIBDDTZ6AzTjRgjz8pTyos9KbXKM="
]
}
}
]
}

Finally, I need to make one minor change to my script. I need to combine the local_ssh_keys and remote_ssh_keys from the policy evaluation into a single value to print to standard output. This requires a small change to the jq command to process the output:

echo $RESULT | jq -r '.result[0].result | .local_ssh_keys + .remote_ssh_keys | join("\n")'

And that’s it! Testing it out is left as an exercise to the reader, but you should be able to successfully authenticate as long as the appropriate SSH public key exists in the data.local_keys JSON or remotely in GitHub.

Wrapping Up

I undertook this experiment because I’m a big fan of both OPA and OpenSSH’s AuthorizedKeysCommand functionality. OPA already provides a robust PAM module, so you could argue that my work here is duplicitous. However, I’m a big fan of leveraging tools that already exist on a system to solve a problem. This small script leverages packages that are likely to be on a system already, without any additional changes to PAM.

The examples I’ve used here involve manual changes to a local policy bundle directory. In practice, I would build this directory into a proper OPA bundle and consider different methods for distributing the bundle. I might also consider running OPA as a local daemon on the system and investigating various methods of distributing external data, though that feels like overkill for such a simple use case.

This article demonstrated a simple foundation that can be extended to facilitate more advanced authentication use cases. OPA is very powerful, and I’ve found a variety of interesting applications for OPA policies. The OpenSSH use case is compelling, because SSH access to underlying hosts is traditionally “simple:” either a user has access, or they do not. Leveraging OPA enables more complex decisions about a user’s access, while still providing a simple implementation free of too much custom code.

--

--