Distinguish between wrong credentials and missing permissions

Hi,

please, is there any reliable way how to figure out in audit if the reason why some call failed (i.e. was forbidden) was because of the user entered wrong credentials, not that he’s missing permissions for that operation? Happened to us several times that we were troubleshooting like mad for hours why some call(s) keep failing just to find out in the end that the user was using wrong credentials. The the message that user gets back (by default Forbidden by ReadonlyREST) is just fine but we should be somehow able to see in audit that the real cause why it failed was that user was not authenticated.

Thanks a lot,

Pavel

Hi @pavelp

In general you should be able to find the information you need in:

  1. ROR audit (in the “acl_history” field)
  2. ROR logs (in the “HIS” part of the log entry)

You can find there information about matched and mismatched rules in each processed block and collected data in these blocks.
They may be helpful to answer the question “why does the user cannot log in?”

Let’s see some example.
I prepare the example in ROR sandbox. You can run it on your side and test it too if you wish:

(running instructions in the PR’s description)

The tested ACL looks like this (in most important part of it obivously):

    - name: "Test 1"
      ldap_authentication: ldap1
      ldap_authorization:
        name: ldap1
        groups_or: ["Group1"]
      indices: ["frontend_logs"]

    - name: "Test 2"
      ldap_authentication: ldap1
      indices: ["frontend_logs"]

First, let’s do the properly authorized request:

curl -k -u user1:test "https://127.0.0.1:19200/frontend_logs/_search"

and in audit we will find:

"acl_history": [
    "[KIBANA-> RULES:[auth_key->false] RESOLVED:[indices=frontend_logs]], [Admins-> RULES:[groups_any_of->false] RESOLVED:[indices=frontend_logs]], [Test 1-> RULES:[ldap_authentication->true, ldap_authorization->true, indices->true] RESOLVED:[user=user1;group=Group1;av_groups=Group1;indices=frontend_logs]]"
  ],

We see that the “Test 1” block allowed the request (all rules in it were allowed too).

Let’s test user2 (it has no access to Group1):

curl -k -u user2:test "https://127.0.0.1:19200/frontend_logs/_search"

"[KIBANA-> RULES:[auth_key->false] RESOLVED:[indices=frontend_logs]], [Admins-> RULES:[groups_any_of->false] RESOLVED:[indices=frontend_logs]], [Test 1-> RULES:[ldap_authentication->true, ldap_authorization->false] RESOLVED:[user=user2;indices=frontend_logs]], [Test 2-> RULES:[ldap_authentication->true, indices->true] RESOLVED:[user=user2;indices=frontend_logs]]"
  ],

We will see that “Test 1” block was not matched because of mismatched ldap_authorization rule.

Now, let’s enter invalid credentils:

curl -k -u user1:invalid_creds "https://127.0.0.1:19200/frontend_logs/_search"

"[KIBANA-> RULES:[auth_key->false] RESOLVED:[indices=frontend_logs]], [Admins-> RULES:[groups_any_of->false] RESOLVED:[indices=frontend_logs]], [Test 1-> RULES:[ldap_authentication->false] RESOLVED:[indices=frontend_logs]], [Test 2-> RULES:[ldap_authentication->false] RESOLVED:[indices=frontend_logs]]"

We see that both blocks: “Test 1” and “Test 2” mismatched because of mismatched “ldap_authentication” rules.

As you can see, we can use this to manual analysis of given cases.
But I must admit that the aproach can be problematic when you use the local groups and users.

We are obivoulsy open to improving this aspect of ROR.
But maybe we should discuss your specific case.
Could you please show us the example from your ACL (the acl_history value) where there is hard to intepret that?

Hi Mateusz,

thanks a lot, I’ll send you more details via pm. The thing is that we use ldap_auth (which according to docs combines ldap_authentication and ldap_authorization rules together) and there does not seem to be any difference in responses (I might be wrong though).

Regards,

Pavel

Yes, ldap_auth is just a composition of ldap_authentication and ldap_authorization rules.

Ok, I checked your logs and your ACL, and I see that you use local groups extensively (which is ok). But at the same time, you are right. In this configuration, it’s hard to tell what has happened inside the “users” section. We should definitely improve it.

I will send you a pre-build when we have the proposition of the improvement.

Thanks for raising it!

Mateusz, thanks a lot for your time and effort. In real-world configuration we mainly use ldap groups with pretty much similar results, I just wanted to quickly show how it looks like and I took the dev server and my admin account.

It would be great if we could see in audit log something like “authentication failed” (or anything else, it’s really up to you) so we can easily figure out that the user uses wrong credentials and instead of neverending troubleshooting we can recommend him to use the right credentials in the first place :slightly_smiling_face: .

Regards,

Pavel

Hi @pavelp

We have something you could test and let us know if the improvements are accurate for you, or maybe we should do more in this context.

If you wish, I can prepare a pre-build for you. Just tell me the ES version you use currently.

Hi Mateusz,

thank you very much, that would be great, we’re on 8.19.4.

Regards,

Pavel

Here it is: ROR 1.69.0-pre3 for ES 8.19.4

The FORBIDDEN log currently looks like this:

[2026-02-04T08:25:58,595][INFO ][t.b.r.a.l.AccessControlListLoggingDecorator] [es-ror-single] [43c0145b-2dc2-4e45-9861-917b771bac0b-843866562#8258] FORBIDDEN by mismatch req={ ID:43c0145b-2dc2-4e45-9861-917b771bac0b-843866562#8258, TYP:RRUserMetadataRequest, CGR:<N/A>, USR:admin (attempted), BRS:true, KDX:null, ACT:cluster:internal_ror/user_metadata/get, OA:172.18.0.4/32, XFF:null, DA:172.18.0.2/32, IDX:<N/A>, MET:GET, PTH:/_readonlyrest/metadata/current_user, CNT:<N/A>, HDR:Host=es-ror:9200, Accept-Encoding=gzip,deflate, x-ror-correlation-id=43c0145b-2dc2-4e45-9861-917b771bac0b, Accept=*/*, User-Agent=node-fetch/1.0 (+https://github.com/bitinn/node-fetch), traceparent=00-de400ec5527247ea2875b7fd42091523-1bbf060b5f013473-00, tracestate=es=s:0, Authorization=<OMITTED>, cookie=__Host-ror.x-csrf-token-MC4wLjAuMDo1NjAx-session_id=b94aad32b9571a5f3bea582128e6ff1d; __Host-ror.x-csrf-token-MC4wLjAuMDo1NjAx=cb0b4ebcb9f2b15e990ce984fca10e5fd8b0a14528056ed50c889d66ca7c7bde.241cc8224b13ecfd3a904d2560315553c28735c729faf3701f2e632baac7c1aca15bae6d5349af9c14c24bf7ec4297082602756d9ed13abc69d0373d9542c2c3, Connection=close, HIS:[KIBANA: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], [Admins: NOT_MATCHED (GROUPS_AUTH_FAIL(admin:AUTH_FAIL(Invalid password); {user1,user2}:GROUPS_AUTH_FAIL(No eligible local groups for user))) -> RULES:[groups_any_of->false]], [User 1 without authroization: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], [End users: NOT_MATCHED (GROUPS_AUTH_FAIL(admin:AUTH_FAIL(Invalid password); {user1,user2}:AUTH_FAIL(Username mismatch))) -> RULES:[groups_any_of->false]], [Business users: NOT_MATCHED (GROUPS_AUTH_FAIL(admin:AUTH_FAIL(Invalid password); user1:AUTH_FAIL(Username mismatch); user2:GROUPS_AUTH_FAIL(No eligible local groups for user))) -> RULES:[groups_any_of->false]] }

for readonlyrest.yml like this:

readonlyrest:

  audit:
    enabled: true
    outputs: [index]

  access_control_rules:

    - name: "KIBANA"
      type: allow
      auth_key: kibana:kibana
      verbosity: error

    - name: "Admins"
      groups: [Administrators]
      kibana:
        access: admin

    - name: "User 1 without authroization"
      auth_key: user1:test
      indices: ["business_logs"]
      kibana:
        access: rw
        index: .kibana_end_@{user}

    - name: "End users"
      groups: ["EndUsers"]
      indices: ["frontend_logs", "kibana_sample_data_*"]
      kibana:
        index: .kibana_end_@{user}
        access: rw
        hide_apps: ["Security", "Observability"]

    - name: "Business users"
      groups: ["BusinessUsers"]
      indices: ["business_logs", "kibana_sample_data_*"]
      kibana:
        index: .kibana_business_@{user}
        access: rw
        hide_apps: ["Security", "Observability"]

  users:
    - username: admin
      auth_key: admin:admin
      groups:
        - id: "Administrators"
          name: "Administrators"
        - id: "EndUsers"
          name: "End Users"
        - id: "BusinessUsers"
          name: "Business Users"

    - username: user1
      auth_key: user1:test
      groups:
        - id: "EndUsers"
          name: "End Users"
        - id: "BusinessUsers"
          name: "Business Users"

    - username: user2
      auth_key: user2:test
      groups:
        - id: "EndUsers"
          name: "End Users"

It’s a little bit verbose. We are thinking about how to do it better.
We are also working on adding it to audit events in a better form than the “HIS” string only.

Please, tell us what you think.

Thank you very much, we have to thoroughly test it (also with members of other teams) and I’ll get back to you once we’re done (which may take a while) with the results.

So far it looks good, it’s absolutely clear from the audit log that the request failed because the user was not authenticated, so in my opinion it’s just fine and no need to further improve it (this is just my opinion though).

1 Like

Hi Mateusz,

I must admit that I’m slightly confused :slightly_smiling_face: . My configuration is as follows (I use the config file from the RoR sandbox “as is” with one slight change - I explicitly gave admin all permissions for all indices):

readonlyrest:

  audit:
    enabled: true
    outputs: [index]

  access_control_rules:

    - name: "KIBANA"
      type: allow
      auth_key: kibana:kibana
      verbosity: error

    - name: "Admins"
      groups: [Administrators]
      indices: ["*"] # P.P. - only change made here
      kibana:
        access: admin

    - name: "End users"
      groups: ["EndUsers"]
      indices: ["frontend_logs", "kibana_sample_data_*"]
      kibana:
        index: .kibana_end_@{user}
        access: rw
        hide_apps: ["Security", "Observability"]

    - name: "Business users"
      groups: ["BusinessUsers"]
      indices: ["business_logs", "kibana_sample_data_*"]
      kibana:
        index: .kibana_business_@{user}
        access: rw
        hide_apps: ["Security", "Observability"]

  users:
    - username: admin
      auth_key: admin:admin
      groups:
        - id: "Administrators"
          name: "Administrators"
        - id: "EndUsers"
          name: "End Users"
        - id: "BusinessUsers"
          name: "Business Users"

    - username: user1
      auth_key: user1:test
      groups:
        - id: "EndUsers"
          name: "End Users"
        - id: "BusinessUsers"
          name: "Business Users"

    - username: user2
      auth_key: user2:test
      groups:
        - id: "EndUsers"
          name: "End Users"

    - username: "*"
      ror_kbn_auth:
        name: "kbn1"
        groups: ["*"]
      groups:
        - local_group:
            id: "EndUsers"
            name: "End Users"
          external_group_ids: [ "*" ]

  ror_kbn:
    - name: kbn1
      signature_key: "9yzBfnLaTYLfGPzyKW9es76RKYhUVgmuv6ZtehaScj5msGpBpa5FWpwk295uJYaaffTFnQC5tsknh2AguVDaTrqCLfM5zCTqdE4UGNL73h28Bg4dPrvTAFQyygQqv4xfgnevBED6VZYdfjXAQLc8J8ywaHQQSmprZqYCWGE6sM3vzNUEWWB3kmGrEKa4sGbXhmXZCvL6NDnEJhXPDJAzu9BMQxn8CzVLqrx6BxDgPYF8gZCxtyxMckXwCaYXrxAGbjkYH69F4wYhuAdHSWgRAQCuWwYmWCA6g39j4VPge5pv962XYvxwJpvn23Y5KvNZ5S5c6crdG4f4gTCXnU36x92fKMQzsQV9K4phcuNvMWkpqVB6xMA5aPzUeHcGytD93dG8D52P5BxsgaJJE6QqDrk3Y2vyLw9ZEbJhPRJxbuBKVCBtVx26Ldd46dq5eyyzmNEyQGLrjQ4qd978VtG8TNT5rkn4ETJQEju5HfCBbjm3urGLFVqxhGVawecT4YM9Rry4EqXWkRJGTFQWQRnweUFbKNbVTC9NxcXEp6K5rSPEy9trb5UYLYhhMJ9fWSBMuenGRjNSJxeurMRCaxPpNppBLFnp8qW5ezfHgCBpEjkSNNzP4uXMZFAXmdUfJ8XQdPTWuYfdHYc5TZWnzrdq9wcfFQRDpDB2zX5Myu96krDt9vA7wNKfYwkSczA6qUQV66jA8nV4Cs38cDAKVBXnxz22ddAVrPv8ajpu7hgBtULMURjvLt94Nc5FDKw79CTTQxffWEj9BJCDCpQnTufmT8xenywwVJvtj49yv2MP2mGECrVDRmcGUAYBKR8G6ZnFAYDVC9UhY46FGWDcyVX3HKwgtHeb45Ww7dsW8JdMnZYctaEU585GZmqTJp2LcAWRcQPH25JewnPX8pjzVpJNcy7avfA2bcU86bfASvQBDUCrhjgRmK2ECR6vzPwTsYKRgFrDqb62FeMdrKgJ9vKs435T5ACN7MNtdRXHQ4fj5pNpUMDW26Wd7tt9bkBTqEGf"

Then I tried to create some sample index for further testing (intended user would be user1, request was executed by user admin from kibana):

POST business_logs/_doc
{
  "timestamp": "2024-01-15T10:30:00Z",
  "user_id": "user123",
  "action": "purchase",
  "revenue": 299.99,
  "product": "Premium Subscription",
  "region": "US-East",
  "status": "completed"
}

The request is forbidden and audit record looks like this:

{
  "_index": "readonlyrest_audit-2026-02-06",
  "_id": "603dfcd7-367d-4e85-bb6d-733b410f8a68-1118993653#59634",
  "_version": 1,
  "_ignored": [
    "acl_history.keyword"
  ],
  "_source": {
    "headers": [
      "x-forwarded-port",
      "x-ror-kibana-index",
      "x-forwarded-proto",
      "content-length",
      "traceparent",
      "x-ror-kibana-request-path",
      "x-ror-current-group",
      "Authorization",
      "Host",
      "tracestate",
      "x-ror-correlation-id",
      "x-ror-kibana-request-method",
      "keep-alive",
      "content-type",
      "cookie",
      "Accept-Charset",
      "connection",
      "x-forwarded-host",
      "x-forwarded-for"
    ],
    "es_cluster_name": "ror-es-cluster",
    "es_node_name": "es-ror-single",
    "acl_history": "[KIBANA: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], [Admins: NOT_MATCHED (AUTHZ_FAIL) -> RULES:[groups_any_of->true, kibana->false]], [End users: NOT_MATCHED (GROUPS_AUTH_FAIL(Current group is not eligible)) -> RULES:[groups_any_of->false]], [Business users: NOT_MATCHED (GROUPS_AUTH_FAIL(Current group is not eligible)) -> RULES:[groups_any_of->false]]",
    "origin": "172.18.0.5/32",
    "match": false,
    "destination": "172.18.0.2/32",
    "final_state": "FORBIDDEN",
    "task_id": 59634,
    "req_method": "POST",
    "type": "IndexRequest",
    "path": "/business_logs/_doc",
    "indices": [],
    "@timestamp": "2026-02-06T11:35:37Z",
    "content_len_kb": 0,
    "correlation_id": "603dfcd7-367d-4e85-bb6d-733b410f8a68",
    "processingMillis": 1,
    "xff": "127.0.0.1",
    "action": "indices:data/write/index",
    "presented_identity": "admin",
    "block": "default",
    "id": "603dfcd7-367d-4e85-bb6d-733b410f8a68-1118993653#59634",
    "content_len": 188,
    "user": "admin"
  },
  "fields": {
    "es_cluster_name": [
      "ror-es-cluster"
    ],
    "es_node_name": [
      "es-ror-single"
    ],
    "headers.keyword": [
      "x-forwarded-port",
      "x-ror-kibana-index",
      "x-forwarded-proto",
      "content-length",
      "traceparent",
      "x-ror-kibana-request-path",
      "x-ror-current-group",
      "Authorization",
      "Host",
      "tracestate",
      "x-ror-correlation-id",
      "x-ror-kibana-request-method",
      "keep-alive",
      "content-type",
      "cookie",
      "Accept-Charset",
      "connection",
      "x-forwarded-host",
      "x-forwarded-for"
    ],
    "origin": [
      "172.18.0.5/32"
    ],
    "presented_identity.keyword": [
      "admin"
    ],
    "destination": [
      "172.18.0.2/32"
    ],
    "final_state": [
      "FORBIDDEN"
    ],
    "task_id": [
      59634
    ],
    "req_method.keyword": [
      "POST"
    ],
    "type": [
      "IndexRequest"
    ],
    "final_state.keyword": [
      "FORBIDDEN"
    ],
    "user.keyword": [
      "admin"
    ],
    "es_cluster_name.keyword": [
      "ror-es-cluster"
    ],
    "path": [
      "/business_logs/_doc"
    ],
    "id.keyword": [
      "603dfcd7-367d-4e85-bb6d-733b410f8a68-1118993653#59634"
    ],
    "xff.keyword": [
      "127.0.0.1"
    ],
    "type.keyword": [
      "IndexRequest"
    ],
    "correlation_id.keyword": [
      "603dfcd7-367d-4e85-bb6d-733b410f8a68"
    ],
    "es_node_name.keyword": [
      "es-ror-single"
    ],
    "action": [
      "indices:data/write/index"
    ],
    "block": [
      "default"
    ],
    "presented_identity": [
      "admin"
    ],
    "id": [
      "603dfcd7-367d-4e85-bb6d-733b410f8a68-1118993653#59634"
    ],
    "content_len": [
      188
    ],
    "action.keyword": [
      "indices:data/write/index"
    ],
    "headers": [
      "x-forwarded-port",
      "x-ror-kibana-index",
      "x-forwarded-proto",
      "content-length",
      "traceparent",
      "x-ror-kibana-request-path",
      "x-ror-current-group",
      "Authorization",
      "Host",
      "tracestate",
      "x-ror-correlation-id",
      "x-ror-kibana-request-method",
      "keep-alive",
      "content-type",
      "cookie",
      "Accept-Charset",
      "connection",
      "x-forwarded-host",
      "x-forwarded-for"
    ],
    "destination.keyword": [
      "172.18.0.2/32"
    ],
    "acl_history": [
      "[KIBANA: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], [Admins: NOT_MATCHED (AUTHZ_FAIL) -> RULES:[groups_any_of->true, kibana->false]], [End users: NOT_MATCHED (GROUPS_AUTH_FAIL(Current group is not eligible)) -> RULES:[groups_any_of->false]], [Business users: NOT_MATCHED (GROUPS_AUTH_FAIL(Current group is not eligible)) -> RULES:[groups_any_of->false]]"
    ],
    "match": [
      false
    ],
    "req_method": [
      "POST"
    ],
    "@timestamp": [
      "2026-02-06T11:35:37.000Z"
    ],
    "content_len_kb": [
      0
    ],
    "origin.keyword": [
      "172.18.0.5/32"
    ],
    "block.keyword": [
      "default"
    ],
    "correlation_id": [
      "603dfcd7-367d-4e85-bb6d-733b410f8a68"
    ],
    "processingMillis": [
      1
    ],
    "xff": [
      "127.0.0.1"
    ],
    "user": [
      "admin"
    ],
    "path.keyword": [
      "/business_logs/_doc"
    ]
  },
  "ignored_field_values": {
    "acl_history.keyword": [
      "[KIBANA: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], [Admins: NOT_MATCHED (AUTHZ_FAIL) -> RULES:[groups_any_of->true, kibana->false]], [End users: NOT_MATCHED (GROUPS_AUTH_FAIL(Current group is not eligible)) -> RULES:[groups_any_of->false]], [Business users: NOT_MATCHED (GROUPS_AUTH_FAIL(Current group is not eligible)) -> RULES:[groups_any_of->false]]"
    ]
  }
}

There are two things that make me confused - why is the request forbidden as admin user should have permissions for all indices and what makes me even more confused is that I see there (AUTH_FAIL(Username mismatch)) as admin user is already authenticated and logged in to Kibana.

Regards,

Pavel

@pavelp

There is:

Admins: NOT_MATCHED (AUTHZ_FAIL) -> RULES:[groups_any_of->true, kibana->false]

It means, that “Admins” block was not matched because the request was not authorized (AUTHZ_FAIL). The “kibana” rule denied the request.

Currently, the main 3 causes that can be seen there are:

  1. AUTH_FAIL - authentication failure
  2. GROUPS_AUTH_FAIL - authorization failure due to not allowed groups
  3. AUTHZ_FAIL - other kind of authorization failure (returned by rules that are NOT authn or/and authz rules)

BTW, I will check why the kibana block denied this request. Looks like it should allow it.

I’m afraid I’m getting more and more confused . How can I possibly get “AUTH_FAIL - authentication failure” if I’m already authenticated and logged in to Kibana (the request was sent from Dev Tools). I mentioned it before but probably it was not clear enough, my bad and I apologize. Btw, if I read this right at the beginning

(AUTH_FAIL(Username mismatch))

I’m not reading any further (and 99% of others won’t read either) - the problem is obvious, moreover the description is quite self-explanatory, so I won’t even reach points 2 and 3 :slightly_smiling_face: .

But the main thing is that the whole concept is rather nonstandard, I would expect getting 401 Unauthorized (that’s why we have this status code after all), not 403. In such a case it would be absolutely clear what happened (even the end user would know immediately) and no further audit analysis is needed (I apologize again, I should have formulated the problem this way right at the beginning).

So the entirely correct (and desired) behaviour would be to return:

401 if user is not authenticated

403 if user is not authorized.

@pavelp

First of all, we should agree on how ROR handles request processing.
I guess, you know that, but let’s make it clear - ROR approach is ACL, not RBAC.

When a request is being handled, each block from the ACL is checked, one by one, until the first one matches, or all are rejected.

You are logged to the Kibana - this is true. ES ROR’s ACL allowed ROR KBN to let you in. But then you opened dev tools and sent the “POST business_logs/_doc” request.

This request was forbidden.
The full history (pretty printed) looks like that:

[KIBANA: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], 
[Admins: NOT_MATCHED (AUTHZ_FAIL) -> RULES:[groups_any_of->true, kibana->false]], 
[End users: NOT_MATCHED (GROUPS_AUTH_FAIL(Current group is not eligible)) -> RULES:[groups_any_of->false]], 
[Business users: NOT_MATCHED (GROUPS_AUTH_FAIL(Current group is not eligible)) -> RULES:[groups_any_of->false]]

We see history for each block:

  1. “KIBANA” block - not matched, because of authentication failure (username was wrong)
  2. “Admins” block - not matched, because (for some readon) “kibana” rules forbid the access
  3. “End users” block - not matched, because the tenancy (current group) was mismatched
  4. “Business users” block - not matched, because the tenancy (current group) was mismatched

So, we cannot say … the request was forbidden because there was an authentication failure. It was, but only for the first block.

I know that it may not be easy to grasp at first glance, but as you can see, it’s related to ROR fundamentals - the ACL processing.

Hi Mateusz,

I still have a bad feeling that we don’t fully understand each other :slightly_smiling_face:. I told you what would be let’s say a standard behaviour (in my opinion) and most user’s natural reaction after reading (AUTH_FAIL(Username mismatch)). Instead you described me how RoR internally works, which I think I’m already kinda familiar with, of course not in-depth understanding but at least its basics as ACL approach.

The thing I can’t wrap my head around (and I might be missing something crucial) is why we need to see all the rules that did not match - as you already mentioned (and it’s clearly stated in the docs) that RoR evaluates the rules one by one until one of them matches (or none at all), so why do we need to see all the rules that did not match, it’s somehow redundand (if we open RoR configuration and find the rule that matched, we can also easily find all the preceding rules that did not match). I think our natural habit is to read from left to right, not from right to left. With that said, some RoR audit setting (just to keep backwards compatibility) to write only the rule that matched would be nice-to-have as preceding unmatched rules are easy to find and following rules are not being evaluated anyway (I apologize if I’m missing something).

Also you completely omitted the 401 vs 403 part… If there is a way to distinguish between wrong credentials in audit log and just not having permissions for some particular action, that also means that RoR “knows” at some point that user is not authenticated. Why not to ,return in such a case standard HTTP code 401 instead of misleading 403 (otherwise we should admit that there is no way how to reliably distinguish between failed authentication and authorization, i.e. insufficient permissions).

Regards,

Pavel

Hi Pavel,

I think the core misunderstanding is about ROR Elasticsearch plugin ACL architecture. Unlike traditional systems where authentication and authorization are two sequential phases (401 vs 403), ROR’s ACL is completely stateless, so there is no separate “authentication phase”.

Each ACL block evaluates credentials, indices, actions, etc as a single unit. AUTH_FAIL(Username mismatch) on block #1 doesn’t mean “the user is not authenticated”, rather, it means that block didn’t match. The same user might have valid credentials for block #2.

After evaluating all blocks, the final result is simply “a block matched” or “no block matched.” There’s no global moment where ROR can definitively say “this is purely an authentication failure,” which is why 401 vs 403 isn’t reliably distinguishable.

WRT logging being verbose: fair point! When you have a lot of blocks it can get rough. We could think about changing it so it only fully lists the rules return values for matched blocks, in info mode. Or something along these lines. We will discuss internally

Sorry Pavel that I was no so precise as you expect me to do.
In my previous post I focused on explainning the ACL and why the FORBIDDEN log look like I showed.
I assumed that it clarify the misunderstanding. I see it’s not, so, I’ll try to exlpain it deeper and refer specifically to what you are asking about.

Let’s start with my understanding of the root of the problem from this thread.
I would use examples. I think this is simplified version of what you have in your ROR settings:

readonlyrest:

  # in this section of ROR, we primarily determine which local groups each user belongs to
  users:
    - username: user1
      auth_key: user1:test # it could be any authentication/authorization rule here - it's not relevant in this example
      groups: [EndUsers, BusinessUsers]

    - username: user2
      auth_key: user2:test # it could be any authentication/authorization rule here - it's not relevant in this example
      groups: [EndUsers]

  access_control_rules:

    # this block is used by the Kibana technical requests only
    - name: "KIBANA"
      type: allow
      auth_key: kibana:kibana
      verbosity: error

    # block that allows access to "frontend_logs" index for "EndUsers" tenant/group
    - name: "End users"
      groups: ["EndUsers"]
      indices: ["frontend_logs"]

    # block that allows access to "business_logs" index for "BusinessUsers" tenant/group
    - name: "Business users"
      groups: ["BusinessUsers"]
      indices: ["business_logs"]

Sometimes your users asked you (admin) why they cannot do something.
Let’s consider adding a new document (the example you used):

Example 1:

The “user2” that has no permission to “business_logs” index, is trying to add a new document:

curl -k -u user2:test -XPOST https://localhost:19200/business_logs/_doc -H "Content-Type: application/json" -d '{"timestamp": "2024-01-15T10:30:00Z","user_id": "user123","action": "purchase","revenue": 299.99,"product": "Premium Subscription","region": "US-East","status": "completed"}'

{"error":{"root_cause":[{"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"}],"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"},"status":403}

In logs (and in the audit too) we can find:

FORBIDDEN by default req={ ID:ea249e8b-0790-457e-a78c-a613ee6cbc82-985068797#20985, TYP:IndexRequest, CGR:<N/A>, USR:user2 (attempted), BRS:true, KDX:null, ACT:indices:data/write/index, OA:172.67.160.34/32, XFF:null, DA:172.18.0.2/32, IDX:business_logs, MET:POST, PTH:/business_logs/_doc, CNT:<OMITTED, LENGTH=173.0 B> , HDR:Accept=*/*, Content-Type=application/json, User-Agent=curl/8.7.1, Host=localhost:19200, Content-Length=173, Authorization=<OMITTED>, HIS:[KIBANA-> RULES:[auth_key->false] RESOLVED:[indices=business_logs]], [End users-> RULES:[groups_any_of->true, indices->false] RESOLVED:[user=user2;group=EndUsers;av_groups=EndUsers;indices=business_logs]], [Business users-> RULES:[groups_any_of->false] RESOLVED:[indices=business_logs]], }

How we can interpret the HIS part?

  • “KIBANA” block was not matched because the “auth_key” rule was not matched (auth_key->false)
  • “End users” block was not matched because the “indices” rule was not matched (indices->false). However, we can see that the “groups_any_of” in this block was matched (groups_any_of->true), so user was authenticated and groups were authorized
  • “Business users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false)

Result:
The request was not matched by any block, so it was forbidden (“FORBIDDEN by default”). ES returned 403 - request was not authorized.


Example 2:
Let’s consider the same request but let’s assume that user entered wrong password:

curl -k -u user2:wrongpassowed -XPOST https://localhost:19200/business_logs/_doc -H "Content-Type: application/json" -d '{"timestamp": "2024-01-15T10:30:00Z","user_id": "user123","action": "purchase","revenue": 299.99,"product": "Premium Subscription","region": "US-East","status": "completed"}'

{"error":{"root_cause":[{"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"}],"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"},"status":403}

In logs (and in the audit too) we can find:

FORBIDDEN by default req={ ID:d506e69f-7955-482e-b7e0-784f7e6efd26-1865827387#25149, TYP:IndexRequest, CGR:<N/A>, USR:user2 (attempted), BRS:true, KDX:null, ACT:indices:data/write/index, OA:172.67.160.34/32, XFF:null, DA:172.18.0.2/32, IDX:business_logs, MET:POST, PTH:/business_logs/_doc, CNT:<OMITTED, LENGTH=173.0 B> , HDR:Accept=*/*, Content-Type=application/json, User-Agent=curl/8.7.1, Host=localhost:19200, Content-Length=173, Authorization=<OMITTED>, HIS:[KIBANA-> RULES:[auth_key->false] RESOLVED:[indices=business_logs]], [End users-> RULES:[groups_any_of->false] RESOLVED:[indices=business_logs]], [Business users-> RULES:[groups_any_of->false] RESOLVED:[indices=business_logs]], }

How we can interpret the HIS part?

  • “KIBANA” block was not matched because the “auth_key” rule was not matched (auth_key->false)
  • “End users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false)
  • “Business users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false)

Result:
The request was not matched by any block, so it was forbidden (“FORBIDDEN by default”). ES returned 403 - request was not authorized.

Comparision with the “Example 1”:
In log of the “Example 1”, we saw that in the “End users” block the “groups_any_of” rule was matched (it means that authentication and groups authorization ended up with success). In this example we see that the “groups_any_of” of the block failed to matched, but it’s hard to reason why. In other blocks we have no clue too.


Example 3:
Let’s consider the same request but let’s assume that non existing user is trying to add new document:

curl -k -u nonexisting:pass -XPOST https://localhost:19200/business_logs/_doc -H "Content-Type: application/json" -d '{"timestamp": "2024-01-15T10:30:00Z","user_id": "user123","action": "purchase","revenue": 299.99,"product": "Premium Subscription","region": "US-East","status": "completed"}'

{"error":{"root_cause":[{"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"}],"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"},"status":403}⏎

In logs (and in the audit too) we can find:

FORBIDDEN by default req={ ID:6a55627e-b283-4f4d-91b1-e6a54e7f2e08-360200855#29062, TYP:IndexRequest, CGR:<N/A>, USR:nonexisting (attempted), BRS:true, KDX:null, ACT:indices:data/write/index, OA:172.67.160.34/32, XFF:null, DA:172.18.0.2/32, IDX:business_logs, MET:POST, PTH:/business_logs/_doc, CNT:<OMITTED, LENGTH=173.0 B> , HDR:Accept=*/*, Content-Type=application/json, User-Agent=curl/8.7.1, Host=localhost:19200, Content-Length=173, Authorization=<OMITTED>, HIS:[KIBANA-> RULES:[auth_key->false] RESOLVED:[indices=business_logs]], [End users-> RULES:[groups_any_of->false] RESOLVED:[indices=business_logs]], [Business users-> RULES:[groups_any_of->false] RESOLVED:[indices=business_logs]], }

How we can interpret the HIS part?

  • “KIBANA” block was not matched because the “auth_key” rule was not matched (auth_key->false)
  • “End users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false)
  • “Business users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false)

Result:
The request was not matched by any block, so it was forbidden (“FORBIDDEN by default”). ES returned 403 - request was not authorized.

Comparision with the “Example 2”:
As we can see we got the same for as in the “Example 2” for non-existing user. So, looking at logs we don’t know if the user doesn’t exist or if the password was wrong. If you would use eg. ldap_auth in the “users” section, we could say the same about group membership. We couldn’t distinquish if user entered wrong credentials or if they simply is not a member of some group configured in the ldap_auth rule.

The 3 examples show the problem I tried to solve.
Before we move on, I will show how the problem is solved in the currently developer ROR ES version:

Example 1 (improved):

curl -k -u user2:test -XPOST https://localhost:19200/business_logs/_doc -H "Content-Type: application/json" -d '{"timestamp": "2024-01-15T10:30:00Z","user_id": "user123","action": "purchase","revenue": 299.99,"product": "Premium Subscription","region": "US-East","status": "completed"}'

{"error":{"root_cause":[{"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"}],"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"},"status":403}

In logs (and in the audit too) we can find:

FORBIDDEN by mismatch req={ ID:1b74ce59-8503-40c4-8899-8543b8372da2-1677372260#5849, TYP:IndexRequest, CGR:<N/A>, USR:user2 (attempted), BRS:true, KDX:null, ACT:indices:data/write/index, OA:172.67.160.34/32, XFF:null, DA:172.18.0.2/32, IDX:business_logs, MET:POST, PTH:/business_logs/_doc, CNT:<OMITTED, LENGTH=173.0 B> , HDR:Accept=*/*, Content-Type=application/json, User-Agent=curl/8.7.1, Host=localhost:19200, Content-Length=173, Authorization=<OMITTED>, HIS:[KIBANA: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], [End users: NOT_MATCHED (AUTHZ_FAIL) -> RULES:[groups_any_of->true, indices->false]], [Business users: NOT_MATCHED (GROUPS_AUTH_FAIL(user2:GROUPS_AUTH_FAIL(No user's groups allowed); user1:AUTH_FAIL(Username mismatch))) -> RULES:[groups_any_of->false]] }

How we can interpret the HIS part this time?

  • “KIBANA” block was not matched because the “auth_key” rule was not matched (auth_key->false). We also see detailed reason: AUTH_FAIL(Username mismatch)
  • “End users” block was not matched because the “indices” rule was not matched (indices->false). However, we can see that the “groups_any_of” in this block was matched (groups_any_of->true), so user was authenticated and groups were authorized. We also see detailed reason: NOT_MATCHED (AUTHZ_FAIL) - it means, “other than groups authorization fail”.
  • “Business users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false). We also see the detailed reason: NOT_MATCHED (GROUPS_AUTH_FAIL(user2:GROUPS_AUTH_FAIL(No user’s groups allowed); user1:AUTH_FAIL(Username mismatch)) - here we see that in the “users” section the “user2” section was rejected because the groups authorization failed. The “user1” section was rejected because of username mismatch.

Result:
The request was not matched by any block, so it was forbidden (“FORBIDDEN by default”). ES returned 403 - request was not authorized. Details in the HIS block say us more about the rejection details.


Example 2 (improved):
Let’s consider the same request but let’s assume that user entered wrong password:

curl -k -u user2:wrongpassowed -XPOST https://localhost:19200/business_logs/_doc -H "Content-Type: application/json" -d '{"timestamp": "2024-01-15T10:30:00Z","user_id": "user123","action": "purchase","revenue": 299.99,"product": "Premium Subscription","region": "US-East","status": "completed"}'

{"error":{"root_cause":[{"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"}],"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"},"status":403}

In logs (and in the audit too) we can find:

FORBIDDEN by mismatch req={ ID:924d2b14-22f5-44dd-9e9a-9869838291d1-669128476#10456, TYP:IndexRequest, CGR:<N/A>, USR:user2 (attempted), BRS:true, KDX:null, ACT:indices:data/write/index, OA:172.67.160.34/32, XFF:null, DA:172.18.0.2/32, IDX:business_logs, MET:POST, PTH:/business_logs/_doc, CNT:<OMITTED, LENGTH=173.0 B> , HDR:Accept=*/*, Content-Type=application/json, User-Agent=curl/8.7.1, Host=localhost:19200, Content-Length=173, Authorization=<OMITTED>, HIS:[KIBANA: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], [End users: NOT_MATCHED (GROUPS_AUTH_FAIL(user2:AUTH_FAIL(Invalid password); user1:AUTH_FAIL(Username mismatch))) -> RULES:[groups_any_of->false]], [Business users: NOT_MATCHED (GROUPS_AUTH_FAIL(user2:GROUPS_AUTH_FAIL(No user's groups allowed); user1:AUTH_FAIL(Username mismatch))) -> RULES:[groups_any_of->false]] }

How we can interpret the HIS part this time?

  • “KIBANA” block was not matched because the “auth_key” rule was not matched (auth_key->false). We also see detailed reason: AUTH_FAIL(Username mismatch)
  • “End users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false). We also see detailed reason: NOT_MATCHED (GROUPS_AUTH_FAIL(user2:AUTH_FAIL(Invalid password); user1:AUTH_FAIL(Username mismatch)). This time we see that “user2” section in the “users” section was rejected because of “Invalid password”.
  • “Business users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false). We also see the detailed reason: NOT_MATCHED (GROUPS_AUTH_FAIL(user2:GROUPS_AUTH_FAIL(No user’s groups allowed); user1:AUTH_FAIL(Username mismatch)) - here we see that in the “users” section the “user2” section was rejected because the groups authorization failed. The “user1” section was rejected because of username mismatch.

Result:
The request was not matched by any block, so it was forbidden (“FORBIDDEN by default”). ES returned 403 - request was not authorized. Details in the HIS block say us more about the rejection details.


Example 3 (improved):

Let’s consider the same request but let’s assume that non existing user is trying to add new document:

curl -k -u nonexisting:pass -XPOST https://localhost:19200/business_logs/_doc -H "Content-Type: application/json" -d '{"timestamp": "2024-01-15T10:30:00Z","user_id": "user123","action": "purchase","revenue": 299.99,"product": "Premium Subscription","region": "US-East","status": "completed"}'

{"error":{"root_cause":[{"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"}],"type":"forbidden_response","reason":"Forbidden by ReadonlyREST","due_to":"OPERATION_NOT_ALLOWED"},"status":403}

In logs (and in the audit too) we can find:

FORBIDDEN by mismatch req={ ID:92bda945-6449-4831-beb6-ece45fefb79d-1514476156#14060, TYP:IndexRequest, CGR:<N/A>, USR:nonexisting (attempted), BRS:true, KDX:null, ACT:indices:data/write/index, OA:172.67.160.34/32, XFF:null, DA:172.18.0.2/32, IDX:business_logs, MET:POST, PTH:/business_logs/_doc, CNT:<OMITTED, LENGTH=173.0 B> , HDR:Accept=*/*, Content-Type=application/json, User-Agent=curl/8.7.1, Host=localhost:19200, Content-Length=173, Authorization=<OMITTED>, HIS:[KIBANA: NOT_MATCHED (AUTH_FAIL(Username mismatch)) -> RULES:[auth_key->false]], [End users: NOT_MATCHED (GROUPS_AUTH_FAIL({user1,user2}:AUTH_FAIL(Username mismatch))) -> RULES:[groups_any_of->false]], [Business users: NOT_MATCHED (GROUPS_AUTH_FAIL(user2:GROUPS_AUTH_FAIL(No user's groups allowed); user1:AUTH_FAIL(Username mismatch))) -> RULES:[groups_any_of->false]] }

How we can interpret the HIS part this time?

  • “KIBANA” block was not matched because the “auth_key” rule was not matched (auth_key->false). We also see detailed reason: AUTH_FAIL(Username mismatch)
  • “End users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false). We also see detailed reason: NOT_MATCHED (GROUPS_AUTH_FAIL({user1,user2}:AUTH_FAIL(Username mismatch))). Both sections in “users” part of ROR settings were not matched because of “Username mismatch”
  • “Business users” block was not matched because the “groups_any_of” was not matched (groups_any_of->false). We also see the detailed reason.

This was a little bit too long preface. Now, I’m going to respond to your questions and comments.

I told you what would be let’s say a standard behaviour (in my opinion) and most user’s natural reaction after reading (AUTH_FAIL(Username mismatch)).

Yes, I cannot agree with it. When you think about typical, most common approach with is RBAC (Role based access control), you can just identified the following use cases:

  1. user is successfully authenticated and has access to resource
  2. user is successfully authenticated but has NOT access to resource
  3. user is NOT successfully authenenticated

But in our context it looks different. Simone explained it well above.
I’d add that ROR’s ACL can be compared to sth what you probably saw in Nginx configuration. AFAIR, in the Nginx configration we could define “locations”. When request in handled by Nginx, it goes though each location (starting from the upper one, going down in the location’s list) and tries to match it. It it matches location, the request is allowed (it’s obivously simplified use case). Then no location is matched, the request is forbidden/rejected.

The same we have it ROR. ROR goes though each block (in each block, it check each rule) and tries to match block. If all rules in the block are matched, the block is matched. When one rule in the block is mismatched, the whole block is considered being mismatched, and ROR tries the next block. When no block was matched, the request is forbidden and ROR returns 403 (always).

Each block could (but don’t have to) have an authentication or/and authorization rule. When the block is checked, the authn/authz/auth rules are checked in the first order. So, we can imagine that block could be rejected because of:

  1. authentication failure (user doesn’t exist, password mismatch)
  2. groups authorization failure (authz/auth rules) - the authenticated user doesn’t belong to the specified groups
  3. general/other authorization failure - e.g. user has no access to given index, or to given ES action, etc.

So, when the request is rejected we could end up with sth like that:

  • “KIBANA” block - authentication failure
  • “End users” block - general authorization failure (cannot access to index)
  • “Business users” block - groups authorization failure

In the ACL approach, we cannot simply said that it was 401 or 403. We know that the request was not forbidden by the ACL defined rules.

that RoR evaluates the rules one by one until one of them matches (or none at all), so why do we need to see all the rules that did not match, it’s somehow redundand (if we open RoR configuration and find the rule that matched, we can also easily find all the preceding rules that did not match).

NOTE: I guess that saying a “rule” here, you had in mind a “block”, right?

I’d answer consider two cases:

  1. FORBIDDEN request

When request was forbidden, ROR went though all blocks and none was matched (TBH, there is the forbidden policy type block, but let’s not focus on this case now). To be able to analyse why your request was FORBIDDEN you have to know why each block was forbidden. That’s why we show all blocks history.

  1. ALLOWED request

When request is allowed, one block was matched. If this was not the first block, the HIS will contain also preceding blocks that was not matched. This information is needed for analysis too. You may ask “Why my request was allowed by this block, not by e.g. one of the preceding blocks”.

I think our natural habit is to read from left to right, not from right to left. With that said, some RoR audit setting (just to keep backwards compatibility) to write only the rule that matched would be nice-to-have as preceding unmatched rules are easy to find and following rules are not being evaluated anyway (I apologize if I’m missing something).

We could prepare such audit serializer that skips all unmatched blocks in history and adds info about the matched block only, but notice that it’s only refer to the 2nd case (see the above). And as you probably already noticed, some information could be lost or at least it would be harder to answer the question “Why my request was allowed by this block, not by e.g. one of the preceding blocks”.

Also you completely omitted the 401 vs 403 part… If there is a way to distinguish between wrong credentials in audit log and just not having permissions for some particular action, that also means that RoR “knows” at some point that user is not authenticated. Why not to ,return in such a case standard HTTP code 401 instead of misleading 403 (otherwise we should admit that there is no way how to reliably distinguish between failed authentication and authorization, i.e. insufficient permissions).

I think I already answet that in my first comment above in this post. Do you feel this one? I’m obviously understand that this is not the “natural” approach in the security layers we know from the most of apps we use on a daily basis. ROR uses the ACL for the historical reasons (maybe Simone reveal to us why this approach was picked ;)). Some things are easier to do in the ACL approach, some in the RBAC. It’s about the foundations. IMO, the problem raised in this tread rubs against the foundations.

Does this mean that we can only throw up our hands? Hell, NO!
We are planning to introduce the RBAC engine to the ROR. The goal is to let user pick the approach: ACL or RBAC. Looking at your ROR settings, I would guess that the RBAC approach will fit your settings well. But it’s in the design phase now.

What’s now? As I shown, we improved the causes a little bit. I understand now, that it doesn’t solve your problem in depth. But as I was trying to prove - IMO it’s about the approach.
Or maybe you have some brilliant idea how we could do it in the ACL approach? How we could make your support easier?
I’d be great if you could show this to us using the examples from this post. Then we will discuss here the potenantial problems of the proposed solution.

Hi guys,

thanks a lot to both of you for your time and effort and comprehensive answers, I really appreciate it. I’m doing some more testing, which is going to take some time and I’ll get back here as soon as I have some (hopefully) reasonable summary.

Regards,

Pavel

2 Likes

Sure thing @pavelp, happy to help anytime :slight_smile: