@@ -23,6 +23,8 @@ public class AnalyticsEngineSecurityIT extends SecurityTestBase {
2323
2424 private static final String TEST_INDEX = "analytics_security_test" ;
2525 private static final String FORBIDDEN_INDEX = "analytics_forbidden_test" ;
26+ private static final String TEST_INDEX_2 = "analytics_security_extra" ;
27+ private static final String TEST_ALIAS = "analytics_alias" ;
2628
2729 private static final String ALLOWED_USER = "analytics_allowed_user" ;
2830 private static final String ALLOWED_ROLE = "analytics_allowed_role" ;
@@ -32,6 +34,10 @@ public class AnalyticsEngineSecurityIT extends SecurityTestBase {
3234 private static final String SEARCH_ONLY_ROLE = "analytics_search_only_role" ;
3335 private static final String WILDCARD_USER = "analytics_wildcard_user" ;
3436 private static final String WILDCARD_ROLE = "analytics_wildcard_role" ;
37+ private static final String ALIAS_USER = "analytics_alias_user" ;
38+ private static final String ALIAS_ROLE = "analytics_alias_role" ;
39+ private static final String EXACT_PERM_USER = "analytics_exact_perm_user" ;
40+ private static final String EXACT_PERM_ROLE = "analytics_exact_perm_role" ;
3541
3642 private static boolean initialized = false ;
3743
@@ -73,30 +79,39 @@ private void createTestIndices() throws IOException {
7379 // Create composite (analytics-engine-backed) indices so the SQL plugin routes
7480 // queries through the analytics engine's DefaultPlanExecutor.
7581 createCompositeIndex (TEST_INDEX );
82+ createCompositeIndex (TEST_INDEX_2 );
83+ createCompositeIndex (FORBIDDEN_INDEX );
84+
85+ RequestOptions .Builder opts = RequestOptions .DEFAULT .toBuilder ();
86+ opts .addHeader ("Content-Type" , "application/x-ndjson" );
87+
7688 Request bulk = new Request ("POST" , "/_bulk" );
7789 bulk .addParameter ("refresh" , "true" );
7890 bulk .setJsonEntity (
7991 String .format (
8092 Locale .ROOT ,
8193 "{\" index\" : {\" _index\" : \" %s\" }}\n {\" name\" : \" alice\" , \" age\" : 30}\n "
82- + "{\" index\" : {\" _index\" : \" %s\" }}\n {\" name\" : \" bob\" , \" age\" : 25}\n " ,
94+ + "{\" index\" : {\" _index\" : \" %s\" }}\n {\" name\" : \" bob\" , \" age\" : 25}\n "
95+ + "{\" index\" : {\" _index\" : \" %s\" }}\n {\" name\" : \" carol\" , \" age\" : 28}\n "
96+ + "{\" index\" : {\" _index\" : \" %s\" }}\n {\" name\" : \" secret\" , \" age\" : 99}\n " ,
8397 TEST_INDEX ,
84- TEST_INDEX ));
85- RequestOptions . Builder opts = RequestOptions . DEFAULT . toBuilder ();
86- opts . addHeader ( "Content-Type" , "application/x-ndjson" );
98+ TEST_INDEX ,
99+ TEST_INDEX_2 ,
100+ FORBIDDEN_INDEX ) );
87101 bulk .setOptions (opts );
88102 client ().performRequest (bulk );
89103
90- createCompositeIndex (FORBIDDEN_INDEX );
91- Request bulkF = new Request ("POST" , "/_bulk" );
92- bulkF .addParameter ("refresh" , "true" );
93- bulkF .setJsonEntity (
104+ // Create alias pointing to TEST_INDEX
105+ Request aliasReq = new Request ("POST" , "/_aliases" );
106+ aliasReq .setJsonEntity (
94107 String .format (
95108 Locale .ROOT ,
96- "{\" index\" : {\" _index\" : \" %s\" }}\n {\" name\" : \" secret\" , \" age\" : 99}\n " ,
97- FORBIDDEN_INDEX ));
98- bulkF .setOptions (opts );
99- client ().performRequest (bulkF );
109+ """
110+ {"actions": [{"add": {"index": "%s", "alias": "%s"}}]}
111+ """ ,
112+ TEST_INDEX ,
113+ TEST_ALIAS ));
114+ client ().performRequest (aliasReq );
100115 }
101116
102117 private void createCompositeIndex (String index ) throws IOException {
@@ -166,6 +181,30 @@ private void createSecurityRolesAndUsers() throws IOException {
166181 "indices:data/read*" , "indices:admin/mappings/get" , "indices:monitor/settings/get"
167182 });
168183 createUser (WILDCARD_USER , WILDCARD_ROLE );
184+
185+ // Role with access only to the alias — verifies security plugin resolves alias to
186+ // concrete index and permits access when role's index_patterns matches the alias name.
187+ createRoleWithPermissions (
188+ ALIAS_ROLE ,
189+ TEST_ALIAS ,
190+ new String [] {"cluster:admin/opensearch/ppl" , "cluster:admin/opensearch/sql" },
191+ new String [] {
192+ "indices:data/read*" , "indices:admin/mappings/get" , "indices:monitor/settings/get"
193+ });
194+ createUser (ALIAS_USER , ALIAS_ROLE );
195+
196+ // Role with exactly indices:data/read/analytics/query — proves this specific permission
197+ // is both necessary and sufficient for analytics engine queries.
198+ createRoleWithPermissions (
199+ EXACT_PERM_ROLE ,
200+ TEST_INDEX ,
201+ new String [] {"cluster:admin/opensearch/ppl" , "cluster:admin/opensearch/sql" },
202+ new String [] {
203+ "indices:data/read/analytics/query" ,
204+ "indices:admin/mappings/get" ,
205+ "indices:monitor/settings/get"
206+ });
207+ createUser (EXACT_PERM_USER , EXACT_PERM_ROLE );
169208 }
170209
171210 @ Test
@@ -216,6 +255,26 @@ public void testPPLQueryDeniedWithSearchPermissionOnly() throws IOException {
216255 executePPLAsUser (
217256 "source = " + TEST_INDEX + " | fields name, age" , SEARCH_ONLY_USER ));
218257 assertEquals (403 , e .getResponse ().getStatusLine ().getStatusCode ());
258+ String body = org .opensearch .sql .legacy .TestUtils .getResponseBody (e .getResponse (), true );
259+ assertTrue (
260+ "Expected response to reference the missing analytics/query action, got: " + body ,
261+ body .contains ("indices:data/read/analytics/query" ));
262+ }
263+
264+ @ Test
265+ public void testPPLQueryAllowedWithExactAnalyticsQueryPermission () throws IOException {
266+ // User has exactly indices:data/read/analytics/query (not a broad wildcard).
267+ // Proves this specific permission is sufficient for analytics engine queries.
268+ try {
269+ JSONObject result =
270+ executePPLAsUser ("source = " + TEST_INDEX + " | fields name, age" , EXACT_PERM_USER );
271+ assertTrue ("Expected datarows in response" , result .has ("datarows" ));
272+ } catch (ResponseException e ) {
273+ assertNotEquals (
274+ "Expected auth to pass (not 403) for user with exact analytics/query permission" ,
275+ 403 ,
276+ e .getResponse ().getStatusLine ().getStatusCode ());
277+ }
219278 }
220279
221280 @ Test
@@ -247,6 +306,93 @@ public void testPPLQueryDeniedWithWildcardPermissionOnNonMatchingIndex() throws
247306 assertEquals (403 , e .getResponse ().getStatusLine ().getStatusCode ());
248307 }
249308
309+ // --- Alias-based access tests ---
310+
311+ @ Test
312+ public void testPPLQueryAllowedViaAlias () throws IOException {
313+ // User's role has index_patterns: ["analytics_alias"]. Security plugin resolves the
314+ // alias to the concrete index. Since AnalyticsQueryRequest uses strictExpandOpen(),
315+ // IndexNameExpressionResolver resolves the alias and security matches it against the
316+ // role's index_patterns which includes the alias name.
317+ try {
318+ JSONObject result =
319+ executePPLAsUser ("source = " + TEST_ALIAS + " | fields name, age" , ALIAS_USER );
320+ assertTrue ("Expected datarows in response" , result .has ("datarows" ));
321+ } catch (ResponseException e ) {
322+ assertNotEquals (
323+ "Expected auth to pass (not 403) for alias-permitted user" ,
324+ 403 ,
325+ e .getResponse ().getStatusLine ().getStatusCode ());
326+ }
327+ }
328+
329+ @ Test
330+ public void testPPLQueryDeniedViaAliasForUnauthorizedUser () throws IOException {
331+ // DENIED_USER has no access to analytics_alias or the underlying index.
332+ ResponseException e =
333+ assertThrows (
334+ ResponseException .class ,
335+ () -> executePPLAsUser ("source = " + TEST_ALIAS + " | fields name, age" , DENIED_USER ));
336+ assertEquals (403 , e .getResponse ().getStatusLine ().getStatusCode ());
337+ }
338+
339+ @ Test
340+ public void testPPLQueryAllowedViaConcreteIndexForAliasUser () throws IOException {
341+ // ALIAS_USER's role has index_patterns: ["analytics_alias"]. In OpenSearch's security
342+ // model, granting access to an alias also implicitly grants access to the underlying
343+ // concrete index. This verifies the query succeeds via the concrete name.
344+ try {
345+ JSONObject result =
346+ executePPLAsUser ("source = " + TEST_INDEX + " | fields name, age" , ALIAS_USER );
347+ assertTrue ("Expected datarows in response" , result .has ("datarows" ));
348+ } catch (ResponseException e ) {
349+ assertNotEquals (
350+ "Expected auth to pass (not 403) for alias user querying concrete index" ,
351+ 403 ,
352+ e .getResponse ().getStatusLine ().getStatusCode ());
353+ }
354+ }
355+
356+ // --- Wildcard index pattern in query tests ---
357+
358+ @ Test
359+ public void testPPLQueryWithWildcardIndexAllowed () throws IOException {
360+ // WILDCARD_USER has index_patterns: ["analytics_security*"]. Query uses wildcard
361+ // "analytics_security*" which resolves to analytics_security_test and
362+ // analytics_security_extra — both match the role's pattern.
363+ try {
364+ JSONObject result =
365+ executePPLAsUser ("source = analytics_security* | fields name, age" , WILDCARD_USER );
366+ assertTrue ("Expected datarows in response" , result .has ("datarows" ));
367+ } catch (ResponseException e ) {
368+ assertNotEquals (
369+ "Expected auth to pass (not 403) for wildcard query with matching permissions" ,
370+ 403 ,
371+ e .getResponse ().getStatusLine ().getStatusCode ());
372+ }
373+ }
374+
375+ @ Test
376+ public void testPPLQueryWithWildcardIndexDenied () throws IOException {
377+ // DENIED_USER has no access to any analytics_* indices.
378+ ResponseException e =
379+ assertThrows (
380+ ResponseException .class ,
381+ () -> executePPLAsUser ("source = analytics_security* | fields name, age" , DENIED_USER ));
382+ assertEquals (403 , e .getResponse ().getStatusLine ().getStatusCode ());
383+ }
384+
385+ @ Test
386+ public void testPPLQueryWithWildcardIndexPartialAccessDenied () throws IOException {
387+ // ALIAS_USER only has access to "analytics_alias" — not to "analytics_security*".
388+ // A wildcard query expanding to indices the user lacks permission for should be denied.
389+ ResponseException e =
390+ assertThrows (
391+ ResponseException .class ,
392+ () -> executePPLAsUser ("source = analytics_security* | fields name, age" , ALIAS_USER ));
393+ assertEquals (403 , e .getResponse ().getStatusLine ().getStatusCode ());
394+ }
395+
250396 @ Test
251397 public void testSQLQueryAllowedForAuthorizedUser () throws IOException {
252398 try {
0 commit comments