A primitive Double A (AAA-minus-Accounting) RBAC system implemented in declarative Nginx config.
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.
- readonly account to /_cluster/* (restricted to GET requests) - used for monitoring
- global readonly account (any URLs but only GET)
- full account (any URLs; any HTTP verbs)
- accounts limited to specific indexes only (some readonly some read/write)
- 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.
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 someinclude
s) 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.
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.
Adding in logging of allowed/denied requests is left as an exercise for the reader.
- @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!