Skip to content

Instantly share code, notes, and snippets.

@jpluscplusm
Last active February 22, 2020 22:36
  • Star 15 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save jpluscplusm/9227777 to your computer and use it in GitHub Desktop.
A primitive Double A (AAA-minus-Accounting) RBAC system implemented in declarative Nginx config

Nginx Double A

A primitive Double A (AAA-minus-Accounting) RBAC system implemented in declarative Nginx config.

Background

So I noticed https://github.com/alexaandru/elastic_guardian, a simple AAA reverse-proxy to sit in front of Elasticsearch. Reading the source and comments tickled my "why is this in code not config?" funnybone.

I asked @alexaandru (https://twitter.com/jpluscplusm/status/438339557906735104) who told me it was mostly the resulting complexity of the nginx config he tried that prompted him to write it.

Well, I have a bit of a thing for implementing things purely in Nginx config which, perhaps, one really really shouldn't. People I've worked with previously will, no doubt, be screaming "you're not bloody kidding!" at the screen. My sincere apologies to those I've hurt in the past ... :-)

@alexaandru let me have a summary of the complexities he was dealing with, and this gist is the result.

Constraints as stated by @alexaandru

  1. readonly account to /_cluster/* (restricted to GET requests) - used for monitoring
  2. global readonly account (any URLs but only GET)
  3. full account (any URLs; any HTTP verbs)
  4. accounts limited to specific indexes only (some readonly some read/write)

My interpretation of the constraints

  • There exist 3 system accounts, and many user accounts
  • The 3 system accounts are
    • "readonly"
    • "readonly_global"
    • "root"
  • The 3 system accounts map to constraints #1, #2 and #3.
  • The user accounts, for the purposes of this test, are
    • "i1_read"
    • "i1_write"
    • "i2_read"
    • "i2_write"
  • The user accounts have access to the testing index prefixes /index1/ and /index2/.
    • Here, a "_read" username suffix indicates GET access only
    • A "_write" username suffix indicates any HTTP method is permitted
      • ... but these naming conventions are not used to mediate access as they may not be present in real life.
  • To simulate real world complexities, the "i1_write" user is also allowed read access to index2.

Practical considerations

It's fairly obvious that a set of HTTP password files and matching nginx prefix location{}s could be configured to allow this access. I suspect the complexities that @alexaandru discovered centre around the fact that this simple nginx setup would have the following drawbacks:

  • Each user who needs access to more than one location (e.g. "root" needs access to at least /index1/ and /index2/) will need to be present in all those locations' htpasswd files, with the inherent problems that duplication brings
  • You'd need to duplicate a load of proxy_(pass|set_header|etc) settings (or use some includes) for each and every location which ultimately needed to hit the Elasticsearch backend
  • I'm not even sure how you'd authenticate a read-only user for GET but not for POST/PUT/DELETE using the normal nginx auth mechanisms.

So, rather than construct a bunch of locations and some interesting config to tie them together, I've come up with the config in this gist. It has the following properties:

  • Each username/password combination is stored in exactly one place
  • All username/password combinations are stored in one file
  • Each user's access groups are specified in one place
  • It can be trivially extended to many more users
  • It can be trivially extended to many more URI prefixes
  • There's nothing Elasticsearch-specific in here: it can be used in front of any HTTP service
  • The nginx server{} which reverse-proxies to Elasticsearch is extremely simple and uses no advanced nginx features or subtle interactions which would complicate extending or modifying it later.

It suffers from the following disadvantages, however:

  • Adding a new index and allowing many existing users access requires a fiddly operation to amend those users' access groups. It's reasonably scriptable, but there's no nice way of allowing it by default.
  • The regex nginx map{} is not simple. The entries are quite cargo-cultable as new groups and indexes are added, but it could look intimidating to new users.
  • There's no exclusion mechanism; access is granted based on group membership (see below): if access must not be granted to a user, that user must not be part of the group which is allowed access.

Implementation

The nginx server{} which reverse-proxies to Elasticsearch denies access via a 403 when the map{} variable $request_denied tells it to. By default, access is denied.

$request_denied is driven by a combination of $request_method, $user_groups and $request_uri.

$user_groups is a map{} which is a @-seperated (and -prefixed and -suffixed) list of the groups to which a user belongs.

Access to a specific URI prefix with a specific HTTP method (or combination of methods) is granted based on membership of a specific group. Different group memberships may allow access to overlapping URI prefixes, and will often reference the same HTTP methods.

Here's the $user_groups map:

map $remote_user $user_groups {
  default          0;
  readonly         @monitoring@;
  global_readonly  @global_ro@;
  root             @global_rw@;
  i1_read          @index1_ro@;
  i1_write         @index1_rw@index2_ro@;
  i2_read          @index2_ro@;
  i2_write         @index2_rw@;
}

@ is used as a separator because it's not a regex metacharacter, which will be handy in $request_denied, below. Prefixes and suffixes are used for some semblance of security in the case of maliciously chosen index names leading to string prefix collisions and priviledge escalation.

Here's the $request_denied "boolean" map (with its comments removed):

map $request_method:$user_groups:$request_uri $request_denied {
  default 1;
  ~^GET:.*@monitoring@.*:/_cluster/ 0;
  ~^GET:.*@global_ro@.*:/ 0;
  ~^[^:]*:.*@global_rw@.*:/ 0;
  ~^GET:.*@index1_ro@.*:/index1/ 0;
  ~^[^:]*:.*@index1_rw@.*:/index1/ 0;
  ~^GET:.*@index2_ro@.*:/index2/ 0;
  ~^[^:]*:.*@index2_rw@.*:/index2/ 0;
}

Essentially, we check a per-request METHOD:GROUPS:URI tuple against a regex, and allow access in certain cases. The visual nastiness is down to the regex nature of the map. It would be possible to construct a set of cascading nginx map{}s which made this final boolean map{} much nicer to look at, but I didn't do that.

Triple A

Adding in logging of allowed/denied requests is left as an exercise for the reader.

Thanks

  • @alexaandru for giving me an interesting late-night challenge to work on
  • @Nginx people for giving us a great tool to use. BUT STOP INTERMINGLING THE NGINX/NGINX-PLUS DOCUMENTATION IT'S REALLY REALLY ANNOYING!
map $remote_user $user_groups {
default 0;
readonly @monitoring@;
global_readonly @global_ro@;
root @global_rw@;
i1_read @index1_ro@;
i1_write @index1_rw@index2_ro@;
i2_read @index2_ro@;
i2_write @index2_rw@;
}
map $request_method:$user_groups:$request_uri $request_denied {
# DENY by default
default 1;
# /_cluster/ GET
~^GET:.*@monitoring@.*:/_cluster/ 0;
# / GET
~^GET:.*@global_ro@.*:/ 0;
# / ALL
~^[^:]*:.*@global_rw@.*:/ 0;
# /index1/ GET
~^GET:.*@index1_ro@.*:/index1/ 0;
# /index1/ ALL
~^[^:]*:.*@index1_rw@.*:/index1/ 0;
# /index2/ GET
~^GET:.*@index2_ro@.*:/index2/ 0;
# /index2/ ALL
~^[^:]*:.*@index2_rw@.*:/index2/ 0;
}
server {
listen 80;
server_name es;
location / {
auth_basic "Elasticsearch";
auth_basic_user_file /etc/nginx/passwd;
if ($request_denied) { return 403 "DENIED: user:$remote_user method:$request_method uri:$request_uri user_groups:$user_groups
"; }
proxy_set_header Remote-User $remote_user;
proxy_set_header User-Groups $user_groups;
proxy_set_header Host real-es;
proxy_pass http://127.0.0.1:80;
}
}
# All passwords are "password"
readonly:$apr1$JyI00QAJ$KDPDMzo87ogsVnEq/nxfg0
global_readonly:$apr1$JyI00QAJ$KDPDMzo87ogsVnEq/nxfg0
root:$apr1$JyI00QAJ$KDPDMzo87ogsVnEq/nxfg0
i1_read:$apr1$JyI00QAJ$KDPDMzo87ogsVnEq/nxfg0
i2_read:$apr1$JyI00QAJ$KDPDMzo87ogsVnEq/nxfg0
i1_write:$apr1$JyI00QAJ$KDPDMzo87ogsVnEq/nxfg0
i2_write:$apr1$JyI00QAJ$KDPDMzo87ogsVnEq/nxfg0
# This curl-friendly server simulates the real Elasticsearch backend, as I
# didn't have one knocking around to play with.
server {
listen 80;
server_name real-es;
location / { return 200 "PERMITTED: user:$http_remote_user method:$request_method uri:$request_uri user_class:$http_user_class
"; }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment