ReadonlyREST (ROR) is a great alternative to Elasticsearch’s X-Pack security module, as it offers multiple advantages, like a better licensing model and price. In this blog post, we’ll explore various methods of setting up authentication and authorization for ELK Stack in ROR.

Access Control Using the ROR Free Plugin for Elasticsearch

The access control list (ACL) is the core of the ReadonlyREST Free plugin for Elasticsearch. And the base of all its security settings. All incoming HTTP requests go through the ACL. Depending on the first matching block of rules, either an “unauthorized” HTTP response is returned, or the incoming HTTP request is passed to Elasticsearch for further execution. ACLs are written inside the readonlyrest.yml file. 

An ACL is composed of a minimum of one block of rules. Each block is named with a string, and can contain one or more rules (allowed hosts, names of the allowed indices, etc.) and a policy type (either forbid or allow, with allow being the default if the type clause is omitted). All blocks of rules are analyzed sequentially, and if no matching block is found, the request gets rejected. You can see the reasons for rejected requests being printed in the Elasticsearch log file.

Here is an example of an ACL made of two blocks of rules:

readonlyrest:

    access_control_rules:

    - name: "Block 1 - only Logstash indices are accessible"
      type: allow # <-- default policy type is "allow", so this line could be omitted
      indices: ["logstash-*"] # <-- This is a rule
      
    - name: "Block 2 - Blocking everything from a network"
      type: forbid 
      hosts: ["10.0.0.0/24"] # <-- this is a rule


Additionally, keep in mind the following: 

  • By default, any incoming HTTP request will be rejected, unless a block of rules in the ACL matches and allows it.
  • It’s a good practice to keep the names of rules short and descriptive, as they are logged in Elasticsearch logs.
  • If you want to allow all requests, write the following block:
 - name: "Block 1 - Allowing anything"
   type: allow

Levels of ACL Rules in ReadonlyREST

There are three levels of ACL rules available in ROR:

  • Network level
  • HTTP level
  • Elasticsearch level

 

Let’s explore how these rules are written and the options available.

Network-Level Rules

These rules are used to allow or forbid requests from particular IP addresses, hosts, or complete network ranges (CIDR blocks). They are written using any of the following keywords:

  • hosts: Matches a request whose source IP address matches one of the specified IP addresses or network ranges. This is the most common rule used to allow or block requests from a host or network. The syntax of this rule looks like this: hosts: ["13.0.0.0/16"].
  • hosts_local: Matches a request whose destination IP address matches one of the specified IP addresses or network ranges inside the ACL block. Most of the time, this happens if the Elasticsearch HTTP API is bound to more than one IP address. The syntax of this rule looks like this: hosts_local: ["127.0.0.1", "127.0.0.2"].

HTTP-Level Rules

These rules are used to evaluate request headers, request methods, and API keys in order to allow or forbid any request. Rules in this category include:

  • x_forwarded_for: Behaves similarly to the hosts rule, but receives the origin IP address (i.e., OA in logs) inside the X-Forwarded-For header. It can replace the hosts rule when Elasticsearch is running behind a load balancer, such as Elastic Load Balancing in AWS, since the IP of load balancers keeps changing. Its syntax looks similar to the hosts rule.For example:x_forwarded_for: ["172.19.1.0/24"]
    TIP: You can use x_forwarded_for: [“0.0.0.0/0”] to match all the requests that come through the load balancer having a valid IP address in the x-forwarded-for header.
  • methods: Matches all the requests with HTTP methods that have been added in the specified rule. The syntax looks like this: methods: [GET, PUT, DELETE]. If you want to block all the delete requests, simply forbid the DELETE requests using this rule.
  • headers: All the specified key:value of HTTP headers should be matched. It’s equivalent to “headers_and” described below.
  • headers_and: Acts the same as the headers rule. That is, the request must have all the listed headers, with the prescribed values. For example: headers_and: ["key1:val_*abc","key2:abc_*"]
  • headers_or: At least one specified key:value of the HTTP header should be matched. For example: headers_or: ["x-myheader:val*","header2:*xy"]
  • maxBodyLength: Matches the requests with a body length less than or equal to an integer. 0 matches a request which has no body.
  • api_keys: Matches with a list of all the API keys expected to be in the header’s X-Api-Key. For example: api_keys: ["abc", "xyz"]

Elasticsearch-Level Rules

These rules enable you to control how data is accessed and what actions are allowed over the indices/cluster. The main Elasticsearch-level rules are:

  • indices: Matches if the request does not target any indices, or targets one or more of the index names declared. For example: indices: ["sales", "logstash-*"]
  • actions: Elasticsearch parses each valid HTTP requests into an internal format. All types of internal-format requests is tagged with a characteristic “action” string. The Actions rule matches if the request action name appears in the provided action list. For example: actions: ["indices:data/read/*"] will only match with requests containing allow read operations whose name starts with “indices:data/read/”. Here is the full list of Elasticsearch actions.
  • filter: Used to implement document-level security  (DLS) in Elasticsearch. Only documents that satisfy the bool query mentioned in the rule argument will be returned. Thus, the rule acts like an additional “filter” to any search request.  Example: filter: '{"query_string":{"query":"user:jerry"}}'
  • fields: Enables field-level security in Elasticsearch, helping return only blacklisted or whitelisted fields. With this rule, you can only provide a full blacklist or whitelist. Greylists (i.e., fields: ["~field1", "field2"]) are not allowed, and Elasticsearch will refuse to boot up if a greylist condition is detected in configuration.
    There are
     two accepted syntaxes for writing a fields rule:Whitelisting mode: Returns only the mentioned fields or fields that start with a given prefix.fields: ["is_*", "username", "user.name.keyword"]Blacklisting mode: Disallows returning mentioned field or fields that start with a given prefix in the list. This mode is recommended, as it is easier to manage and remember.fields: ["~price*","~gender" ~user.email"]
    Nested fields are written in dot notation. For example:  nested_field.some_field
  • snapshots: Restricts the name of snapshots that can be taken or restored in the cluster. For example, the rule snapshots: ["snap_twitter_*"] will only allow you to restore snapshots in this Elasticsearch cluster whose names begin with snap_twitter.
  • repositories: Similar to snapshots, this rule puts a restriction on the names of the repositories that can be created for this cluster. For example, repositories: ["repo_twitter_*"] will allow snapshots to be saved only in the repositories whose names start with repo_twitter.

 

If you want to control access to Elasticsearch from Kibana, ROR provides additional rules, such as kibana_access and kibana_index.

How to Implement Authentication and Authorization Using ReadonlyREST

Users and Groups

HTTP requests are allowed or forbidden based on credentials. The credentials can be extracted via HTTP basic auth (authorization header) or forwarded to a reverse proxy.

Credentials validation (authentication) can be done in many ways: locally with hardcoded credentials (plain text or hashes) saved inside the readonlyrest.yml filevia one or more LDAP servers.  Or even externally, by forwarding the basic auth header to an external web server and analyzing the HTTP status code.Or even blindly accepting an HTTP header coming from a trusted reverse proxy in charge of authentication (i.e. the username is taken from the  X-Forwarded-User HTTP header)

Or even validating and parsing an incoming JWT token in the authorization header.

You can also use groups, which are like a container of users. This allows you to write only one ACL block for a group, which will then be applied to a set of users. Groups can be defined, declared, and associated to users in the readonlyrest.yml file. Alternatively, given some  credentials, groups can be fetched from an LDAP server or a custom external JSON/XML service. 

Here is a basic example of managing users via groups:

access_control_rules:
    - name: Accept requests from users defined in group developer1 on index1
      type: allow  # Optional, defaults to "allow".
      groups: ["developer"]
      indices: ["index1"]

    - name: Accept requests from users defined in group admin on index2
      groups: ["admin"]
      indices: ["index2"]

    - name: Accept requests from users defined in groups developer or admin on index3
      groups: ["developer", "admin"]
      indices: ["index3"]

    users:

    - username: bob
      auth_key: bob:p551phrase
      groups: ["developer"]

    - username: charlie
      auth_key: charlie:a3br37
      groups: ["developer", "admin"]

Environment Variables

While you can write credentials in a ROR configuration file, it’s not good practice to include such sensitive information in a file. Instead, you can load this information inside environment variables and use the expression ${MY_ENV_VAR} to replace the credentials with the environmental variables anywhere inside the readonlyrest.yml file. This is the preferred way of setting credentials like LDAP bind passwords, especially in a Docker environment.

For example, here we declare an environment variable LDAP_PASSWORD on our terminal before running Elasticsearch:

export LDAP_PASSWORD="Password_21"

Or we can add this line to a Dockerfile, before launching a container with docker run -e LDAP_PASSWORD=”Password_21″

ENV LDAP_PASSWORD 

So now inside our YAML settings, we can write the place holder ${LDAP_PASSWORD} where we usually would put the LDAP server password.

bind_password: "${LDAP_PASSWORD}"

This is a good alternative to writing credentials inside a Docker file or inside a version control system such as GIT.

There are several ways, including very secure ones (i.e. using vault and consul) to store the environment variable. 

Authentication in ReadonlyREST

An incoming HTTP request can be authenticated by ReadonlyREST plugin for Elasticsearch in multiple ways:

Basic auth (local):  users and their credentials can be defined within the readonlyrest.yml file. Local authentication can be considered secure only secured if SSL is enabled. You can learn more about enabling SSL in ROR here. Local authentication is implemented in three different rules which differ only for the hashing algorithm used to store the credentials:

  • auth_key: The credentials are written in clear text (e.g., auth_key: sales:p545w, where “sales” is the username and “p545w” is the password).  This rule is not recommended in production since credentials are written in clear text in the YAML file. Either auth_key_sha256 or auth_key_unix rules should replace it in production.
  • auth_key_sha256: Like above, the value of this rule is a string representing the username and the password, but the password hashed this time is hashed with SHA256 (e.g., auth_key_sha256: "sales:280ac6f...94bf9"). For authentication using this rule, send a request with the usual authorization header.
  • auth_key_unix: This is an alternate way of generating credentials using a heavier, multi-pass hashing algorithm. Depending on the hashing algorithm, this rule can be CPU intensive. Read more about how to generate hashed keys here.

 

Delegated authentication: In this scenario, the clients connecto to a reverse proxy in charge to authenticate the client (with whatever mechanism) and  subsequently forwarding the request with and added HTTP header representing the authenticated user’s username. Normally the header is X-Forwarded-User. To implement this, we can use the the proxy_auth rule inside the readonlyrest.yml file. Here is an example of delegated authentication. 

readonlyrest:

  access_control_rules:

  - name: "::Tweets::"
    type: allow
    methods: GET
    indices: ["twitter"]
    proxy_auth:
      proxy_auth_config: "proxy1"
      users: ["*"]

  - name: "::Facebook posts::"
    type: allow
    methods: GET
    indices: ["facebook"]
    proxy_auth:
      proxy_auth_config: "proxy1"
      users: ["*"]

  proxy_auth_configs:

  - name: "proxy1"
    user_id_header: "X-Forwarded-User"

Note: users:[*] allows all the username requests to be sent inside the X-Forwarded-User request. To authenticate only specific users, write: users:["tom", "alice"].

For delegated authentication using the ROR Kibana plugins, add the following snippet to the kibana.yml so that Kibana will forward the necessary headers to Elasticsearch:

readonlyrest_kbn.proxy_auth_passthrough: true

Other authentication strategies:

External basic authentication: This is used to delegate authentication to another server that supports HTTP basic auth. ROR forwards the received authorization header to an external web server and then verifies the returned HTTP status code with the provided credentials. This is useful if you have a web server that holds all the required credentials and the credentials are sent inside the authorization header.

 

JSON Web Token (JWT) authentication: JWT is a very popular authentication method, where the username and other information belonging to the user can be extracted from the so-called “claims” sent inside it.

 

LDAP authentication: User credentials are validated via the LDAP server. Both users and groups are managed inside the LDAP server itself. We’ll discuss this more in the next section.

 

SAML authentication: Supported by the ROR Enterprise plugin. The SAML connector supports both SSO (single sign-on) and SLO (single logout).

LDAP-Based Authentication 

LDAP user authentication is a popular technique used in mid to large-size organizations to validate a username and password in combination with a directory server, such as OpenLDAP, Microsoft Active Directory, or OpenDJ. LDAP user authentication can be done by resolving the username and groups to a directory entry attribute or validating the user password stored inside the LDAP server.

Note: Keep an eye on your Elasticsearch log file for exceptions raised due to misconfiguration in any parameter or unexpected output. In most scenarios, Elasticsearch won’t start in the case of misconfiguration or an unreachable LDAP server.

Important: For added security, and to avoid your users’ LDAP credentials being sent in plain text in unencrypted HTTP, we highly recommend that you enable SSL inside the readonlyrest.yml file. You can read more about ROR SSL encryption here.

The following screenshots were taken from PhpLDAPadmin, a web interface for LDAP server administration. They should give you a clearer understanding of the entities created inside LDAP for the example configuration we’re using in this post.

Top-level entities created inside LDAP
1. Top-level entities created inside LDAP

 

Configuration of the two groups created inside LDAP
2. Configuration of the two groups created inside LDAP

 

Configuration of the two users created inside LDAP
3. Configuration of the two users created inside LDAP

 

 

ROR provides an LDAP connector to authenticate with LDAP servers, with a rich set of configuration options as shown below.

readonlyrest:
    ssl:
      keystore_file: "keystore.jks"
      keystore_pass: qwerty123
      key_pass: qwerty123

    access_control_rules:
    - name: Accept requests from users in group team1 on index_1 and index_2
      type: allow                     # Optional, defaults to "allow", will omit from now on.
      indices: ["index_1", "index_2"]
      ldap_auth:
        name: "ldap1"                 # ldap name from below 'ldaps' section
        groups: ["ldap_group1"]       # group within 'ou=Groups,dc=foo,dc=example,dc=org'
     

    - name: Accept requests from users in group team2 on index_3
      indices: ["index_3"]
      ldap_auth:
        name: "ldap1"
        groups: ["ldap_group2"]
        cache_ttl_in_sec: 60
 
    ldaps:
    - name: ldap1
      host: "localhost"
      port: 389                                                     # optional, default 389
      ssl_enabled: false                                            # optional, default true
      ssl_trust_all_certs: true                                     # optional, default false
      bind_dn: "cn=admin,dc=foo,dc=example,dc=org"                  # optional, skip for anonymous bind
      bind_password: "Password_21"                                  # optional, skip for anonymous bind
      search_user_base_DN: "ou=Users,dc=foo,dc=example,dc=org"
      user_id_attribute: "uid"                                      # optional, default "uid"
      search_groups_base_DN: "ou=Groups,dc=foo,dc=example,dc=org"
      unique_member_attribute: "uniqueMember"                       # optional, default "uniqueMember"
      connection_pool_size: 10                                      # optional, default 30
      connection_timeout_in_sec: 10                                 # optional, default 1
      request_timeout_in_sec: 10                                    # optional, default 1
      cache_ttl_in_sec: 60                                          # optional, default 0 - cache disabled

Let’s review some of the important configuration parameters shown above.

ldaps

This section contains all the parameters of LDAP configuration. As shown above, you can configure one or more LDAP servers using a named parameter (e.g., ldap1, ldap2, etc.). These different LDAP configurations can be referenced inside access control rules in the  ldap_auth section. For testing purposes, simply omit everything in the ldap2 section, including its reference.

search_user_base_DN 

This refers to the user’s base distinguished name. By default, users in search_user_base_DN should have an LDAP attribute with the name uid. This uid acts as a unique ID for the user inside the base DN. You can use an alternative attribute name, which can be specified with the optional user_id_attribute parameter. Inside LDAP, It’s important to select RDN as the user name (uid) while creating the users, as shown in Image 5, below.

Choosing RDN while creating a user in LDAP
5. Choosing RDN while creating a user in LDAP

 

Once Elasticsearch starts, we’ll try creating and deleting an index from the localhost server without any credentials, with the commands shown in Image 6, below.

Related content

HOW CERN SAVES MONEY WITH READONLYREST

This year, CERN (The European Organization for Nuclear Research) optimized the usage of computing resources by consolidating 30+ Elasticsearch clusters into a handful of multi-user clusters.

Watch the presentation CERN organized to understand the guiding principles behind ReadonlyREST.