Skip to content

Commit 7787bc2

Browse files
authored
Inject user custom attributes (#5560)
Signed-off-by: Mark Boyd <[email protected]>
1 parent f2f317a commit 7787bc2

File tree

12 files changed

+232
-27
lines changed

12 files changed

+232
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1414
* Added new option skip_users to client cert authenticator (clientcert_auth_domain.http_authenticator.config.skip_users in config.yml)([#4378](https://github.com/opensearch-project/security/pull/5525))
1515
* [Resource Sharing] Fixes accessible resource ids search by marking created_by.user field as keyword search instead of text ([#5574](https://github.com/opensearch-project/security/pull/5574))
1616
* [Resource Sharing] Reverts @Inject pattern usage for ResourceSharingExtension to client accessor pattern. ([#5576](https://github.com/opensearch-project/security/pull/5576))
17+
* Inject user custom attributes when injecting user and role information to the thread context ([#5560](https://github.com/opensearch-project/security/pull/5560))
1718

1819
### Refactoring
1920

src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import static org.junit.Assert.assertEquals;
7070
import static org.junit.Assert.assertFalse;
7171
import static org.junit.Assert.assertNotEquals;
72+
import static org.junit.Assert.assertNotNull;
7273
import static org.junit.Assert.assertTrue;
7374
import static org.junit.Assert.fail;
7475

@@ -94,7 +95,6 @@
9495
DocumentPrivilegesTest.DataStreams_getRestriction.class,
9596
DocumentPrivilegesTest.DlsQuery.class })
9697
public class DocumentPrivilegesTest {
97-
9898
static NamedXContentRegistry xContentRegistry = new NamedXContentRegistry(
9999
ImmutableList.of(
100100
new NamedXContentRegistry.Entry(
@@ -364,23 +364,25 @@ public void queryPatternTemplate() throws Exception {
364364
new TestSecurityConfig.Role("non_dls_role").indexPermissions("*").on("index_a*")
365365
);
366366

367-
DocumentPrivileges subject = createSubject(roleConfig);
367+
Exception exception = null;
368+
DlsRestriction dlsRestriction = null;
369+
DocumentPrivileges subject = null;
368370

369-
DlsRestriction dlsRestriction = subject.getRestriction(context, index.getName());
371+
try {
372+
subject = createSubject(roleConfig);
373+
dlsRestriction = subject.getRestriction(context, index.getName());
374+
} catch (PrivilegesEvaluationException ex) {
375+
exception = ex;
376+
}
370377

371378
if (index == index_b1) {
372379
// This test case never grants privileges to index_b1
373380
assertThat(dlsRestriction, isFullyRestricted());
374381
} else if (userSpec.attributes.isEmpty()) {
375-
// If a role uses undefined user attributes for DLS queries, the attribute templates
376-
// remain unchanged in the resulting query. This is a property of the current attribute handling code.
377-
// It would be probably better if an error would be raised in that case.
382+
// If a role uses undefined user attributes for DLS queries, an exception should be raised
378383
if (index == index_a1) {
379384
if (userSpec.roles.contains("dls_role_1") && userSpec.roles.contains("dls_role_2")) {
380-
assertThat(
381-
dlsRestriction,
382-
isRestricted(termQuery("dept", "${attr.attr_a}1"), termQuery("dept", "${attr.attr_a}2"))
383-
);
385+
assertNotNull(exception);
384386
}
385387
}
386388
} else if (userSpec.roles.contains("non_dls_role") && dfmEmptyOverridesAll) {
@@ -417,7 +419,7 @@ public void queryPatternTemplate() throws Exception {
417419
}
418420

419421
boolean isUnrestricted = subject.isUnrestricted(context, index.getName());
420-
if (dlsRestriction.isUnrestricted()) {
422+
if (dlsRestriction != null && dlsRestriction.isUnrestricted()) {
421423
assertTrue("isUnrestricted() should return true according to " + dlsRestriction, isUnrestricted);
422424
} else {
423425
assertFalse("isUnrestricted() should return false according to " + dlsRestriction, isUnrestricted);

src/main/java/org/opensearch/security/auth/RolesInjector.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
package org.opensearch.security.auth;
1717

18+
import java.util.Map;
1819
import java.util.Set;
1920

2021
import com.google.common.collect.ImmutableSet;
@@ -70,7 +71,11 @@ public Set<String> injectUserAndRoles(final ThreadPool threadPool) {
7071
return null;
7172
}
7273
Set<String> roles = ImmutableSet.copyOf(strs[1].split(","));
73-
User user = new User(strs[0]).withSecurityRoles(roles);
74+
75+
Map<String, String> customAttributes = threadPool.getThreadContext()
76+
.getTransient(ConfigConstants.OPENDISTRO_SECURITY_INJECTED_USER_CUSTOM_ATTRIBUTES);
77+
78+
User user = new User(strs[0]).withSecurityRoles(roles).withAttributes(customAttributes);
7479

7580
addUser(user, threadPool);
7681
return roles;

src/main/java/org/opensearch/security/privileges/IndexPattern.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ public void add(List<String> source) {
220220

221221
if (indexPattern.startsWith("<") && indexPattern.endsWith(">")) {
222222
this.dateMathExpressions.add(indexPattern);
223-
} else if (!containsPlaceholder(indexPattern)) {
223+
} else if (!UserAttributes.needsAttributeSubstitution(indexPattern)) {
224224
this.constantPatterns.add(WildcardMatcher.from(indexPattern));
225225
} else {
226226
this.patternTemplates.add(indexPattern);
@@ -241,10 +241,6 @@ public IndexPattern build() {
241241
}
242242
}
243243

244-
static boolean containsPlaceholder(String indexPattern) {
245-
return indexPattern.indexOf("${") != -1;
246-
}
247-
248244
public static IndexPattern from(List<String> source) {
249245
Builder builder = new Builder();
250246
builder.add(source);

src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626

2727
package org.opensearch.security.privileges;
2828

29-
import java.io.Serializable;
3029
import java.util.ArrayList;
3130
import java.util.Arrays;
3231
import java.util.Collections;
@@ -307,7 +306,7 @@ private void setUserInfoInThreadContext(PrivilegesEvaluationContext context) {
307306
log.debug(joiner);
308307

309308
if (this.isUserAttributeSerializationEnabled()) {
310-
joiner.add(Base64Helper.serializeObject((Serializable) context.getUser().getCustomAttributesMap()));
309+
joiner.add(Base64Helper.serializeObject(new HashMap<>(context.getUser().getCustomAttributesMap())));
311310
}
312311

313312
threadContext.putTransient(OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT, joiner.toString());

src/main/java/org/opensearch/security/privileges/TenantPrivileges.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public TenantPrivileges(
104104
for (RoleV7.Tenant tenantPermissions : role.getTenant_permissions()) {
105105
List<ActionType> actionTypes = resolveActionType(tenantPermissions.getAllowed_actions(), actionGroups);
106106
for (String tenantPattern : tenantPermissions.getTenant_patterns()) {
107-
if (isDynamic(tenantPattern)) {
107+
if (UserAttributes.needsAttributeSubstitution(tenantPattern)) {
108108
// If a tenant pattern contains a user attribute (like ${user.name}), we can only
109109
// do the tenant pattern matching during the actual tenant privilege evaluation, when the user is known.
110110
// Thus, we just keep these patterns here unprocessed
@@ -252,10 +252,6 @@ static List<ActionType> resolveActionType(Collection<String> allowedActions, Fla
252252
}
253253
}
254254

255-
static boolean isDynamic(String tenantPattern) {
256-
return tenantPattern.contains("${");
257-
}
258-
259255
private static ImmutableMap<ActionType, ImmutableCompactSubSet<String>> build(
260256
Map<ActionType, DeduplicatingCompactSubSetBuilder.SubSetBuilder<String>> source,
261257
DeduplicatingCompactSubSetBuilder.Completed<String> completedRoleSetBuilder

src/main/java/org/opensearch/security/privileges/UserAttributes.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
* This code was moved over from ConfigModelV7.
2626
*/
2727
public class UserAttributes {
28+
public static boolean needsAttributeSubstitution(String patternString) {
29+
return patternString.contains("${");
30+
}
31+
2832
public static String replaceProperties(String orig, PrivilegesEvaluationContext context) {
2933
User user = context.getUser();
3034

src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import org.apache.logging.log4j.util.Strings;
2020

21+
import org.opensearch.OpenSearchSecurityException;
2122
import org.opensearch.cluster.metadata.IndexAbstraction;
2223
import org.opensearch.common.settings.Settings;
2324
import org.opensearch.common.xcontent.json.JsonXContent;
@@ -44,7 +45,6 @@
4445
* Instances of this class are managed by DlsFlsProcessedConfig.
4546
*/
4647
public class DocumentPrivileges extends AbstractRuleBasedPrivileges<DocumentPrivileges.DlsQuery, DlsRestriction> {
47-
4848
private final NamedXContentRegistry xContentRegistry;
4949

5050
public DocumentPrivileges(
@@ -134,7 +134,7 @@ protected QueryBuilder parseQuery(String queryString, NamedXContentRegistry xCon
134134

135135
static DlsQuery create(String queryString, NamedXContentRegistry xContentRegistry)
136136
throws PrivilegesConfigurationValidationException {
137-
if (queryString.contains("${")) {
137+
if (UserAttributes.needsAttributeSubstitution(queryString)) {
138138
return new DlsQuery.Dynamic(queryString, xContentRegistry);
139139
} else {
140140
return new DlsQuery.Constant(queryString, xContentRegistry);
@@ -174,6 +174,12 @@ static class Dynamic extends DlsQuery {
174174
@Override
175175
RenderedDlsQuery evaluate(PrivilegesEvaluationContext context) throws PrivilegesEvaluationException {
176176
String effectiveQueryString = UserAttributes.replaceProperties(this.queryString, context);
177+
if (UserAttributes.needsAttributeSubstitution(effectiveQueryString)) {
178+
throw new PrivilegesEvaluationException(
179+
"Invalid DLS query: " + effectiveQueryString,
180+
new OpenSearchSecurityException("User attribute substitution failed")
181+
);
182+
}
177183
try {
178184
return new RenderedDlsQuery(parseQuery(effectiveQueryString, xContentRegistry), effectiveQueryString);
179185
} catch (Exception e) {

src/main/java/org/opensearch/security/support/ConfigConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public class ConfigConstants {
131131

132132
public static final String OPENDISTRO_SECURITY_INJECTED_USER = "injected_user";
133133
public static final String OPENDISTRO_SECURITY_INJECTED_USER_HEADER = "injected_user_header";
134+
public static final String OPENDISTRO_SECURITY_INJECTED_USER_CUSTOM_ATTRIBUTES = "injected_user_custom_attributes";
134135

135136
public static final String OPENDISTRO_SECURITY_XFF_DONE = OPENDISTRO_SECURITY_CONFIG_PREFIX + "xff_done";
136137

src/test/java/org/opensearch/security/RolesInjectorIntegTest.java

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.nio.file.Path;
1919
import java.util.ArrayList;
2020
import java.util.Collection;
21+
import java.util.Map;
2122
import java.util.function.Supplier;
2223

2324
import com.google.common.collect.Lists;
@@ -30,11 +31,18 @@
3031
import org.opensearch.action.admin.indices.create.CreateIndexResponse;
3132
import org.opensearch.action.admin.indices.exists.indices.IndicesExistsRequest;
3233
import org.opensearch.action.admin.indices.exists.indices.IndicesExistsResponse;
34+
import org.opensearch.action.admin.indices.refresh.RefreshResponse;
35+
import org.opensearch.action.index.IndexRequest;
36+
import org.opensearch.action.index.IndexResponse;
37+
import org.opensearch.action.search.SearchRequest;
38+
import org.opensearch.action.search.SearchResponse;
3339
import org.opensearch.cluster.health.ClusterHealthStatus;
3440
import org.opensearch.cluster.metadata.IndexNameExpressionResolver;
3541
import org.opensearch.cluster.service.ClusterService;
3642
import org.opensearch.common.settings.Settings;
43+
import org.opensearch.common.util.concurrent.ThreadContext;
3744
import org.opensearch.core.common.io.stream.NamedWriteableRegistry;
45+
import org.opensearch.core.rest.RestStatus;
3846
import org.opensearch.core.xcontent.NamedXContentRegistry;
3947
import org.opensearch.env.Environment;
4048
import org.opensearch.env.NodeEnvironment;
@@ -55,12 +63,13 @@
5563

5664
import static org.hamcrest.MatcherAssert.assertThat;
5765
import static org.hamcrest.Matchers.is;
66+
import static org.opensearch.action.support.WriteRequest.RefreshPolicy.IMMEDIATE;
5867

5968
public class RolesInjectorIntegTest extends SingleClusterTest {
60-
6169
public static class RolesInjectorPlugin extends Plugin implements ActionPlugin {
6270
Settings settings;
6371
public static String injectedRoles = null;
72+
public static Map<String, String> injectedCustomAttributes = null;
6473

6574
public RolesInjectorPlugin(final Settings settings, final Path configPath) {
6675
this.settings = settings;
@@ -82,6 +91,8 @@ public Collection<Object> createComponents(
8291
) {
8392
if (injectedRoles != null) threadPool.getThreadContext()
8493
.putTransient(ConfigConstants.OPENDISTRO_SECURITY_INJECTED_ROLES, injectedRoles);
94+
if (injectedCustomAttributes != null) threadPool.getThreadContext()
95+
.putTransient(ConfigConstants.OPENDISTRO_SECURITY_INJECTED_USER_CUSTOM_ATTRIBUTES, injectedCustomAttributes);
8596
return new ArrayList<>();
8697
}
8798
}
@@ -172,5 +183,83 @@ public void testRolesInject() throws Exception {
172183
IndicesExistsResponse ier = node.client().admin().indices().exists(new IndicesExistsRequest("captain-logs-3")).actionGet();
173184
Assert.assertTrue(ier.isExists());
174185
}
186+
187+
// 4. With a role using DLS and attribute substitution, but with no attributes specified in the thread context
188+
RolesInjectorPlugin.injectedRoles = "valid_user|role_with_dls";
189+
try (
190+
Node node = new PluginAwareNode(
191+
false,
192+
tcSettings,
193+
Lists.newArrayList(Netty4ModulePlugin.class, OpenSearchSecurityPlugin.class, RolesInjectorPlugin.class)
194+
).start()
195+
) {
196+
waitForInit(node.client());
197+
198+
CreateIndexResponse cir = node.client().admin().indices().create(new CreateIndexRequest("captain-logs-4")).actionGet();
199+
Assert.assertTrue(cir.isAcknowledged());
200+
} catch (OpenSearchSecurityException ex) {
201+
exception = ex;
202+
log.warn(ex.toString());
203+
}
204+
Assert.assertNotNull(exception);
205+
Assert.assertTrue(exception.getMessage().contains("Error while evaluating DLS/FLS privileges"));
206+
207+
// 5. With a role using DLS and attribute substitution and with attributes specified in the thread context
208+
// First we need to use a role without DLS to write data to the index. Roles with DLS restrictions cannot perform writes.
209+
RolesInjectorPlugin.injectedRoles = "valid_user|role_without_dls_write";
210+
try (
211+
Node node = new PluginAwareNode(
212+
false,
213+
tcSettings,
214+
Lists.newArrayList(Netty4ModulePlugin.class, OpenSearchSecurityPlugin.class, RolesInjectorPlugin.class)
215+
).start()
216+
) {
217+
waitForInit(node.client());
218+
219+
CreateIndexResponse cir = node.client().admin().indices().create(new CreateIndexRequest("captain-logs-5")).actionGet();
220+
Assert.assertTrue(cir.isAcknowledged());
221+
222+
IndicesExistsResponse ier = node.client().admin().indices().exists(new IndicesExistsRequest("captain-logs-5")).actionGet();
223+
Assert.assertTrue(ier.isExists());
224+
225+
Map<String, String> document = Map.of("starship", "enterprise");
226+
Map<String, String> document2 = Map.of("starship", "voyager");
227+
228+
IndexResponse idr = node.client()
229+
.index(new IndexRequest().setRefreshPolicy(IMMEDIATE).index("captain-logs-5").id("1").source(document))
230+
.actionGet();
231+
Assert.assertEquals(idr.status(), RestStatus.CREATED);
232+
233+
IndexResponse idr2 = node.client()
234+
.index(new IndexRequest().setRefreshPolicy(IMMEDIATE).index("captain-logs-5").id("2").source(document2))
235+
.actionGet();
236+
Assert.assertEquals(idr2.status(), RestStatus.CREATED);
237+
238+
RefreshResponse rer = node.client().admin().indices().prepareRefresh("captain-logs-5").get();
239+
Assert.assertEquals(rer.getStatus(), RestStatus.OK);
240+
241+
SearchResponse ser = clusterHelper.nodeClient().search(new SearchRequest("captain-logs-5")).actionGet();
242+
Assert.assertEquals(RestStatus.OK, ser.status());
243+
Assert.assertEquals(2, ser.getHits().getTotalHits().value());
244+
Assert.assertTrue(ser.toString().contains("enterprise"));
245+
Assert.assertTrue(ser.toString().contains("voyager"));
246+
247+
// Now use a role with DLS and custom attributes to test that attribute substitution works
248+
// and searched documents are filtered correctly.
249+
ThreadPool tp = clusterHelper.nodeClient().threadPool();
250+
try (ThreadContext.StoredContext ignored = tp.getThreadContext().stashContext()) {
251+
ThreadContext tc = tp.getThreadContext();
252+
tc.putTransient(ConfigConstants.OPENDISTRO_SECURITY_INJECTED_ROLES, "valid_user|role_with_dls");
253+
tc.putTransient(
254+
ConfigConstants.OPENDISTRO_SECURITY_INJECTED_USER_CUSTOM_ATTRIBUTES,
255+
Map.of("attr.proxy.starship", "enterprise")
256+
);
257+
258+
SearchResponse serDls = clusterHelper.nodeClient().search(new SearchRequest("captain-logs-5")).actionGet();
259+
Assert.assertEquals(RestStatus.OK, serDls.status());
260+
Assert.assertEquals(1, serDls.getHits().getTotalHits().value());
261+
Assert.assertTrue(serDls.toString().contains("enterprise"));
262+
}
263+
}
175264
}
176265
}

0 commit comments

Comments
 (0)