Lab SE2: Authorization, DLS, and FLS
Prerequisite: Lab SE1 (a secured node is up, you can authenticate as
adminand asalice). 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:
- Index a small
orders-*dataset with a sensitive field (ssn) and a tenant field (region). - 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.
- Add a DLS filter so the user sees only their region's docs.
- Add FLS to hide
ssn, and field masking to hashemail. - Verify each, as two different users, with
curl. - 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
adminworking overhttps+--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(roleall_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:
| As | docs | ssn | email |
|---|---|---|---|
admin (all_access) | all 3 | shown | cleartext |
alice (orders_reader) | 2 (EU only, DLS) | absent (FLS) | hashed (masking) |
Deliverables
-
The captured
403from Step 2 (no role) and the200from Step 3 (after mapping) for the same request. -
The
403on 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) andDlsFlsValveImpl/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
PrivilegesEvaluatorinsideSecurityFilter— the binary 403/not-403 decision, on the action name + resolved index names. - DLS/FLS/masking are
DlsFlsValveImpl+ the securityIndexSearcherWrapperat the shard — they shape which docs and fields come back, only for users whose roles carry those rules.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Still 403 after mapping | role mapping didn't bind (typo in username) or index pattern doesn't cover the index | check api/rolesmapping/orders_reader; confirm orders-2026 matches orders-* |
403 on a wildcard/alias but not the concrete index | index resolution doesn't match the pattern | PrivilegesEvaluator index resolution; widen index_patterns or fix the alias |
| DLS returns 0 hits unexpectedly | DLS 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 intermittently | results served from a cache keyed without DLS | known hazard; ensure request/query cache includes DLS (don't disable security caches blindly) |
| FLS field still visible | wrong field name or include-mode confusion | ~field excludes; a bare list is include-only; don't mix |
| Masked field looks empty instead of hashed | masking algo unsupported or field not a string | use field::SHA-256; masking applies to string-y fields |
| Change has no effect at all | edited YAML but didn't apply | re-run securityadmin.sh (Lab SE1) or use the REST API consistently |
Expected output
- Step 2:
403. Step 3:200+ 3 hits, then a403on write. - Step 4: 2 hits (EU only) for alice; 3 for admin.
- Step 5: alice's
_sourcehas nossn; thessnterms agg is empty for alice. - Step 6: alice's
emailis a stable hex hash; admin's is cleartext.
Stretch goals
-
Give
alicea 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 roleindex_patterns: ["orders"](the alias, notorders-*), 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.
-
(warm-up) A
PrivilegesEvaluatorunit test: allow vs deny. Find its tests (rg -l "PrivilegesEvaluator" src/test). Add a case that evaluatesindices:data/read/searchonorders-2026for a user mapped toorders_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*". -
(core) Assert the action group is read-only. Extend Exercise 1: evaluate
indices:data/write/indexfor theorders_readeruser and assert it is denied even thoughread/searchis 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). -
(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 withdls: term region:EUandfls: ~ssn, and asserts asalice: exactly 2 hits, ids 1 and 2 only, andssnabsent from every_source. Then assert the aggregation leak guard — atermsagg onssnreturns no buckets for alice (Step 5). -
(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 securityIndexSearcherWrapperANDs 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). -
(core) Field-masking stability test. Add an assertion that
email::SHA-256masking 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. -
(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 aMockLogAppender/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.mdentry + DCOSigned-off-by(git commit -s). Security fixes get intense review — see community-interaction.
Validation / self-check
-
Explain why the Step 2 request is a
403and not a401(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.