Lab SE2: Authorization, DLS, and FLS

Prerequisite: Lab SE1 (a secured node is up, you can authenticate as admin and as alice). Concept reading: Security — Intensive, the authz + DLS/FLS sections.

Background

Lab SE1 got you authenticated — the cluster knows who you are. This lab is about everything that happens after that: authorization (may you do this?) and data visibility (which docs and fields do you see?). You will:

  1. Index a small orders-* dataset with a sensitive field (ssn) and a tenant field (region).
  2. Define a role with index permissions built from an action group, map a user to it, and prove a 403 for an action the role does not grant.
  3. Add a DLS filter so the user sees only their region's docs.
  4. Add FLS to hide ssn, and field masking to hash email.
  5. Verify each, as two different users, with curl.
  6. Read the source that enforces it (PrivilegesEvaluator, DlsFlsValveImpl).

Why this matters for contributors

The authz + DLS/FLS code is where the highest-stakes security bugs live: a wrong index-pattern resolution leaks a whole index; a DLS cache-key omission leaks another user's documents; an FLS gap leaks a PII field through an aggregation. Issue triage on opensearch-project/security constantly involves reproducing exactly the flow in this lab. Being able to construct a minimal role + mapping + DLS/FLS repro — and read the evaluator that decides it — is the core skill for contributing fixes there.

Prerequisites

  • A secured node from Lab SE1, with admin working over https + --cacert.
  • An alias for brevity (adjust password/CA path):
alias osadmin='curl -s --cacert config/root-ca.pem -u admin:<demo-admin-password> -H "Content-Type: application/json"'
alias osalice='curl -s --cacert config/root-ca.pem -u alice:Alice-Strong-Passw0rd! -H "Content-Type: application/json"'

Step-by-step tasks

Step 1 — Index a dataset with sensitive + tenant fields

osadmin -XPUT 'https://localhost:9200/orders-2026' -d'
{ "mappings": { "properties": {
  "region":  { "type": "keyword" },
  "email":   { "type": "keyword" },
  "ssn":     { "type": "keyword" },
  "total":   { "type": "double" }
}}}'

osadmin -XPOST 'https://localhost:9200/orders-2026/_bulk' -d'
{"index":{"_id":"1"}}
{"region":"EU","email":"a@eu.example","ssn":"111-11-1111","total":42.0}
{"index":{"_id":"2"}}
{"region":"EU","email":"b@eu.example","ssn":"222-22-2222","total":99.0}
{"index":{"_id":"3"}}
{"region":"US","email":"c@us.example","ssn":"333-33-3333","total":13.0}
'
osadmin -XPOST 'https://localhost:9200/orders-2026/_refresh'
  • Confirm admin (role all_access) sees all 3 docs with all fields:
osadmin 'https://localhost:9200/orders-2026/_search?pretty&filter_path=hits.hits._source'

Step 2 — Prove a 403 (no role yet)

alice exists (from Lab SE1) but is mapped to nothing. She is authenticated but not authorized for any index action:

osalice -o /dev/null -w "%{http_code}\n" 'https://localhost:9200/orders-2026/_search'
# 403

That 403 is SecurityFilter → PrivilegesEvaluator denying indices:data/read/search on orders-2026 because no role grants it to alice. Read the denial path:

grep -rn "class SecurityFilter\|class PrivilegesEvaluator\|evaluate(\|OpenSearchSecurityException\|MISSING_PRIVILEGES" \
  src/main/java/org/opensearch/security/filter/SecurityFilter.java \
  src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java | head
  • Capture the 403. This is the baseline; everything below is granting access.

Step 3 — Define an action group + role, map the user

First an action group (reusable bundle of action names). The built-in READ already exists; define a custom one to see the mechanism:

osadmin -XPUT 'https://localhost:9200/_plugins/_security/api/actiongroups/ORDERS_READ' -d'
{
  "allowed_actions": [ "indices:data/read/search", "indices:data/read/get" ],
  "type": "index"
}'

Now a role granting that action group on orders-*:

osadmin -XPUT 'https://localhost:9200/_plugins/_security/api/roles/orders_reader' -d'
{
  "cluster_permissions": [ "cluster:monitor/health" ],
  "index_permissions": [
    { "index_patterns": [ "orders-*" ],
      "allowed_actions": [ "ORDERS_READ" ] }
  ]
}'

Finally a role mapping binding alice to it:

osadmin -XPUT 'https://localhost:9200/_plugins/_security/api/rolesmapping/orders_reader' -d'
{ "users": [ "alice" ], "backend_roles": [ "ops-team" ] }'
  • Now the same search that returned 403 returns 200 with all 3 docs:
osalice 'https://localhost:9200/orders-2026/_search?pretty&filter_path=hits.hits._source'
# 3 hits, all fields
  • Prove the role is still scoped: a write must still 403 (the action group is read-only):
osalice -o /dev/null -w "%{http_code}\n" -XPOST 'https://localhost:9200/orders-2026/_doc' -d'{"region":"EU"}'
# 403 — indices:data/write/index not in ORDERS_READ

Step 4 — DLS: alice sees only EU docs

Add a dls clause to the role so the user can only see region: EU:

osadmin -XPUT 'https://localhost:9200/_plugins/_security/api/roles/orders_reader' -d'
{
  "cluster_permissions": [ "cluster:monitor/health" ],
  "index_permissions": [
    { "index_patterns": [ "orders-*" ],
      "dls": "{\"term\": {\"region\": \"EU\"}}",
      "allowed_actions": [ "ORDERS_READ" ] }
  ]
}'

Verify — alice now sees only the 2 EU docs, even with match_all:

osalice 'https://localhost:9200/orders-2026/_search?pretty&filter_path=hits.total,hits.hits._id'
# hits.total.value == 2 ; ids 1 and 2 only, never 3 (US)

The US doc is invisible below the user's query — DlsQueryParser parsed the term region:EU filter and the security IndexSearcherWrapper ANDed it into the search at the shard. admin (no DLS) still sees all 3.

grep -rn "class DlsFlsValveImpl\|class DlsQueryParser\|IndexSearcherWrapper\|getDlsQuery" \
  src/main/java/org/opensearch/security/configuration/ | head
  • Prove DLS is enforced under the query: even {"query":{"term":{"region":"US"}}} returns 0 hits for alice (she can't reach US docs at all):
osalice -XGET 'https://localhost:9200/orders-2026/_search?pretty&filter_path=hits.total' -d'
{ "query": { "term": { "region": "US" } } }'
# hits.total.value == 0

Step 5 — FLS: hide the ssn field

Add an FLS exclude (leading ~) so ssn never reaches alice:

osadmin -XPUT 'https://localhost:9200/_plugins/_security/api/roles/orders_reader' -d'
{
  "cluster_permissions": [ "cluster:monitor/health" ],
  "index_permissions": [
    { "index_patterns": [ "orders-*" ],
      "dls": "{\"term\": {\"region\": \"EU\"}}",
      "fls": [ "~ssn" ],
      "allowed_actions": [ "ORDERS_READ" ] }
  ]
}'

Verify — alice's hits have region/email/total but no ssn:

osalice 'https://localhost:9200/orders-2026/_search?pretty&filter_path=hits.hits._source'
# {"region":"EU","email":"a@eu.example","total":42.0}  -- ssn absent
  • Prove FLS also hides the field from aggregations (a common surprise — and a real source of leak bugs if it didn't):
osalice -XGET 'https://localhost:9200/orders-2026/_search?filter_path=aggregations' -d'
{ "size": 0, "aggs": { "ssns": { "terms": { "field": "ssn" } } } }'
# empty / no buckets for alice; admin would get buckets

Step 6 — Field masking: hash email

Masking keeps the field present but replaces its value with a hash:

osadmin -XPUT 'https://localhost:9200/_plugins/_security/api/roles/orders_reader' -d'
{
  "cluster_permissions": [ "cluster:monitor/health" ],
  "index_permissions": [
    { "index_patterns": [ "orders-*" ],
      "dls": "{\"term\": {\"region\": \"EU\"}}",
      "fls": [ "~ssn" ],
      "masked_fields": [ "email::SHA-256" ],
      "allowed_actions": [ "ORDERS_READ" ] }
  ]
}'

Verify — alice's email is now a hex hash, not cleartext, but the same input always hashes the same (so she can still correlate):

osalice 'https://localhost:9200/orders-2026/_search?pretty&filter_path=hits.hits._source.email'
# "email": "a3f1...long hex..."   (stable; a@eu.example always maps to the same hash)
  • Build the full picture in one table by running each user against the same search and recording what each sees:
Asdocsssnemail
admin (all_access)all 3showncleartext
alice (orders_reader)2 (EU only, DLS)absent (FLS)hashed (masking)

Deliverables

  • The captured 403 from Step 2 (no role) and the 200 from Step 3 (after mapping) for the same request.
  • The 403 on a write proving the action group is read-only.
  • The role JSON at its final state (DLS + FLS + masking together) and the role-mapping JSON.
  • The two-user comparison table from Step 6, plus the grep lines locating PrivilegesEvaluator (authz) and DlsFlsValveImpl/DlsQueryParser (DLS).

How it is enforced (read this against the captures)

flowchart TD
    Req["alice: GET orders-2026/_search"] --> SF["SecurityFilter (ActionFilter)"]
    SF --> PE["PrivilegesEvaluator.evaluate\nuser -> roles -> allowed actions\nresolve orders-2026 vs index_patterns"]
    PE -->|not allowed| F403["403 (Step 2)"]
    PE -->|allowed + DLS/FLS rules| Valve["DlsFlsValveImpl at the shard"]
    Valve --> DLS["DlsQueryParser: term region:EU\ninjected as filter (Step 4)"]
    Valve --> FLS["FieldInfos filtered: drop ssn (Step 5)"]
    Valve --> Mask["email -> SHA-256 on read (Step 6)"]
    DLS --> Resp["alice: 2 EU docs, no ssn, hashed email"]
    FLS --> Resp
    Mask --> Resp
  • Authz (allow/deny) is PrivilegesEvaluator inside SecurityFilter — the binary 403/not-403 decision, on the action name + resolved index names.
  • DLS/FLS/masking are DlsFlsValveImpl + the security IndexSearcherWrapper at the shard — they shape which docs and fields come back, only for users whose roles carry those rules.

Troubleshooting

SymptomCauseFix
Still 403 after mappingrole mapping didn't bind (typo in username) or index pattern doesn't cover the indexcheck api/rolesmapping/orders_reader; confirm orders-2026 matches orders-*
403 on a wildcard/alias but not the concrete indexindex resolution doesn't match the patternPrivilegesEvaluator index resolution; widen index_patterns or fix the alias
DLS returns 0 hits unexpectedlyDLS query field/value wrong (e.g. region is text not keyword)check the mapping; DLS term needs an exact-match field
DLS shows too many docs intermittentlyresults served from a cache keyed without DLSknown hazard; ensure request/query cache includes DLS (don't disable security caches blindly)
FLS field still visiblewrong field name or include-mode confusion~field excludes; a bare list is include-only; don't mix
Masked field looks empty instead of hashedmasking algo unsupported or field not a stringuse field::SHA-256; masking applies to string-y fields
Change has no effect at alledited YAML but didn't applyre-run securityadmin.sh (Lab SE1) or use the REST API consistently

Expected output

  • Step 2: 403. Step 3: 200 + 3 hits, then a 403 on write.
  • Step 4: 2 hits (EU only) for alice; 3 for admin.
  • Step 5: alice's _source has no ssn; the ssn terms agg is empty for alice.
  • Step 6: alice's email is a stable hex hash; admin's is cleartext.

Stretch goals

  • Give alice a second role with a different DLS (e.g. region: US) and confirm she now sees the union (EU OR US) — the permissive multi-role DLS semantics. Read where the DLS queries are OR-combined.
  • Demonstrate the alias trap: create an alias orders → orders-2026, give a role index_patterns: ["orders"] (the alias, not orders-*), and observe whether searching the alias vs the concrete index behaves as you expect. This is the #1 "should be allowed but 403" class.
  • Switch FLS from exclude (~ssn) to include-only (["region","total"]) and predict, then verify, exactly which fields survive.

Coding Exercises

You proved authz + DLS/FLS with curl as two users; now make each rule an executable assertion against a clone of opensearch-project/security. Find every class with rg first (rg -l "class PrivilegesEvaluator" src/main); signatures drift, so never trust a stale line number.

  1. (warm-up) A PrivilegesEvaluator unit test: allow vs deny. Find its tests (rg -l "PrivilegesEvaluator" src/test). Add a case that evaluates indices:data/read/search on orders-2026 for a user mapped to orders_reader (expect allowed) and for an unmapped user (expect denied) — the in-code form of the 200 vs 403 from Steps 2–3. Verify with ./gradlew test --tests "*PrivilegesEvaluator*".

  2. (core) Assert the action group is read-only. Extend Exercise 1: evaluate indices:data/write/index for the orders_reader user and assert it is denied even though read/search is allowed — encoding the Step 3 "write must still 403" check. Read how the evaluator resolves an action group to action names (rg -n "actiongroup|resolveActions|ActionGroupHolder" src/main).

  3. (core) A DLS/FLS rule test: only EU docs, no ssn. This is the heart of the lab. Find the DLS/FLS test scaffold (rg -l "DlsFls|class Dls|class Fls" src/test src/integrationTest) and write an integration test that indexes the Step 1 dataset, applies a role with dls: term region:EU and fls: ~ssn, and asserts as alice: exactly 2 hits, ids 1 and 2 only, and ssn absent from every _source. Then assert the aggregation leak guard — a terms agg on ssn returns no buckets for alice (Step 5).

  4. (core) Assert DLS is enforced below the user's query. Add a case to the test above: alice issues {"query":{"term":{"region":"US"}}} and gets 0 hits. This proves the security IndexSearcherWrapper ANDs the DLS filter beneath the user query rather than replacing it. Read where the DLS query is parsed and injected (rg -n "class DlsQueryParser|getDlsQuery|IndexSearcherWrapper" src/main/java/org/opensearch/security/configuration).

  5. (core) Field-masking stability test. Add an assertion that email::SHA-256 masking is deterministic: index the same email in two docs and assert both hash to the same hex value for alice, while admin sees cleartext. Locate the masking implementation (rg -n "masked_fields|MaskedField|SHA-256" src/main) and assert on the actual masking method, not just the REST output.

  6. (advanced) A multi-role DLS union test. Advanced challenge: model the Stretch goal in code. Give alice a second role with dls: term region:US, then write an integration test asserting she now sees the union (EU OR US = all 3 docs) — the permissive multi-role DLS semantics — and a MockLogAppender/explain assertion that the two DLS queries were OR-combined, not AND-combined (AND would wrongly yield 0). Read where the per-role DLS queries are merged (rg -n "OR|should|combine|getDlsQueries|BooleanQuery" $(rg -l "class DlsFlsValveImpl" src/main)). The deliverable is one integ test that pins the OR semantics — a real source of leak/over-restriction bugs if it ever flipped.

Issues to Practice On

The highest-stakes security bugs live on this authz + DLS/FLS path. Hunt them on opensearch-project/security (labels move; confirm on the tracker):

gh issue list --repo opensearch-project/security --label "bug" --state open --search "DLS OR FLS OR privilege OR authorization OR leak OR 403"
gh issue list --repo opensearch-project/security --label "good first issue" --state open
gh issue list --repo opensearch-project/security --label "security vulnerability" --state open
gh label list --repo opensearch-project/security | grep -iE "dls|fls|privilege|authz|leak|good first"

Representative patterns. (1) "Role grants the alias but searching the concrete index 403s" (the alias trap) — reproduce with the Stretch-goal alias setup, trace index resolution in PrivilegesEvaluator via rg, fix the resolution, add a regression test. (2) "DLS occasionally returns another user's docs" — a cache key missing the DLS context: reproduce with concurrent users sharing a cache, locate the cache-key build, fix, and add a test that fails without the DLS in the key. Arc: reproduce → locate via rg → fix → test → PR with a CHANGELOG.md entry and DCO sign-off.

Planted-bug drill. In the FLS field-filtering path (locate with rg -n "fls|filterFields|FieldInfos" $(rg -l "class DlsFlsValveImpl" src/main)), flip the include/exclude sense so ~ssn includes ssn instead of dropping it. Run the DLS/FLS test suite and watch which test goes red — that test is the PII-leak guard. Revert, then add the aggregation-leak assertion from Exercise 3 so an FLS gap that only shows up through aggregations is also caught.

Etiquette: claim the issue first, reproduce before theorising, and every PR ships a test + CHANGELOG.md entry + DCO Signed-off-by (git commit -s). Security fixes get intense review — see community-interaction.

Validation / self-check

  • Explain why the Step 2 request is a 403 and not a 401 (compare to Lab SE1's failures): authn succeeded, authz failed.
  • State which class makes the allow/deny decision and which enforces DLS/FLS, and why they must be in different layers (one is per-action, one is per-shard inside Lucene).
  • Show that DLS is enforced below the user's query by getting 0 hits when alice explicitly searches for a US doc.
  • Reproduce the full role (DLS + FLS + masking) from memory and predict the two-user comparison table before running it.