Clear Realm Caches on role mapping health change (elastic/x-pack-elasticsearch#3782)

If any of the follow take place on security index, then any cached role mappings
are potentially invalid and the associated realms need to clear any cached users.
- Index recovers from red
- Index is deleted
- Index becomes out-of-date / not-out-of-date

Original commit: elastic/x-pack-elasticsearch@1bcd86fcd4
This commit is contained in:
Tim Vernum 2018-02-01 10:01:57 +11:00 committed by GitHub
parent a627fec53e
commit 415bb7f039
6 changed files with 262 additions and 117 deletions

View File

@ -424,6 +424,9 @@ public class Security extends Plugin implements ActionPlugin, IngestPlugin, Netw
components.add(realms);
components.add(reservedRealm);
securityLifecycleService.addSecurityIndexHealthChangeListener(nativeRoleMappingStore::onSecurityIndexHealthChange);
securityLifecycleService.addSecurityIndexOutOfDateListener(nativeRoleMappingStore::onSecurityIndexOutOfDateChange);
AuthenticationFailureHandler failureHandler = null;
String extensionName = null;
for (SecurityExtension extension : securityExtensions) {

View File

@ -11,6 +11,7 @@ import org.elasticsearch.client.Client;
import org.elasticsearch.cluster.ClusterChangedEvent;
import org.elasticsearch.cluster.ClusterState;
import org.elasticsearch.cluster.ClusterStateListener;
import org.elasticsearch.cluster.health.ClusterHealthStatus;
import org.elasticsearch.cluster.health.ClusterIndexHealth;
import org.elasticsearch.cluster.service.ClusterService;
import org.elasticsearch.common.component.AbstractComponent;
@ -214,4 +215,21 @@ public class SecurityLifecycleService extends AbstractComponent implements Clust
public boolean isSecurityIndexOutOfDate() {
return securityIndex.isIndexUpToDate() == false;
}
/**
* Is the move from {@code previousHealth} to {@code currentHealth} a move from an unhealthy ("RED") index state to a healthy
* ("non-RED") state.
*/
public static boolean isMoveFromRedToNonRed(ClusterIndexHealth previousHealth, ClusterIndexHealth currentHealth) {
return (previousHealth == null || previousHealth.getStatus() == ClusterHealthStatus.RED)
&& currentHealth != null && currentHealth.getStatus() != ClusterHealthStatus.RED;
}
/**
* Is the move from {@code previousHealth} to {@code currentHealth} a move from index-exists to index-deleted
*/
public static boolean isIndexDeleted(ClusterIndexHealth previousHealth, ClusterIndexHealth currentHealth) {
return previousHealth != null && currentHealth == null;
}
}

View File

@ -12,6 +12,7 @@ import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.support.ContextPreservingActionListener;
import org.elasticsearch.client.Client;
import org.elasticsearch.cluster.health.ClusterIndexHealth;
import org.elasticsearch.common.CheckedBiConsumer;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.component.AbstractComponent;
@ -58,6 +59,8 @@ import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN;
import static org.elasticsearch.xpack.core.ClientHelper.executeAsyncWithOrigin;
import static org.elasticsearch.xpack.core.ClientHelper.stashWithOrigin;
import static org.elasticsearch.xpack.security.SecurityLifecycleService.SECURITY_INDEX_NAME;
import static org.elasticsearch.xpack.security.SecurityLifecycleService.isIndexDeleted;
import static org.elasticsearch.xpack.security.SecurityLifecycleService.isMoveFromRedToNonRed;
/**
* This store reads + writes {@link ExpressionRoleMapping role mappings} in an Elasticsearch
@ -79,6 +82,18 @@ public class NativeRoleMappingStore extends AbstractComponent implements UserRol
private static final String SECURITY_GENERIC_TYPE = "doc";
private static final ActionListener<Object> NO_OP_ACTION_LISTENER = new ActionListener<Object>() {
@Override
public void onResponse(Object o) {
// nothing
}
@Override
public void onFailure(Exception e) {
// nothing
}
};
private final Client client;
private final SecurityLifecycleService securityLifecycleService;
private final List<String> realmsToRefresh = new CopyOnWriteArrayList<>();
@ -301,6 +316,17 @@ public class NativeRoleMappingStore extends AbstractComponent implements UserRol
listener.onResponse(usageStats);
}
public void onSecurityIndexHealthChange(ClusterIndexHealth previousHealth, ClusterIndexHealth currentHealth) {
if (isMoveFromRedToNonRed(previousHealth, currentHealth) || isIndexDeleted(previousHealth, currentHealth)) {
refreshRealms(NO_OP_ACTION_LISTENER, null);
}
}
public void onSecurityIndexOutOfDateChange(boolean prevOutOfDate, boolean outOfDate) {
assert prevOutOfDate != outOfDate : "this method should only be called if the two values are different";
refreshRealms(NO_OP_ACTION_LISTENER, null);
}
private <Result> void refreshRealms(ActionListener<Result> listener, Result result) {
String[] realmNames = this.realmsToRefresh.toArray(new String[realmsToRefresh.size()]);
final SecurityClient securityClient = new SecurityClient(client);

View File

@ -7,7 +7,6 @@ package org.elasticsearch.xpack.security.authz.store;
import org.apache.logging.log4j.message.ParameterizedMessage;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.cluster.health.ClusterHealthStatus;
import org.elasticsearch.cluster.health.ClusterIndexHealth;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.bytes.BytesReference;
@ -34,6 +33,7 @@ import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilege;
import org.elasticsearch.xpack.core.security.authz.privilege.IndexPrivilege;
import org.elasticsearch.xpack.core.security.authz.privilege.Privilege;
import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
import org.elasticsearch.xpack.security.SecurityLifecycleService;
import java.util.ArrayList;
import java.util.Arrays;
@ -53,6 +53,8 @@ import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import static org.elasticsearch.xpack.core.security.SecurityField.setting;
import static org.elasticsearch.xpack.security.SecurityLifecycleService.isIndexDeleted;
import static org.elasticsearch.xpack.security.SecurityLifecycleService.isMoveFromRedToNonRed;
/**
* A composite roles store that combines built in roles, file-based roles, and index-based roles. Checks the built in roles first, then the
@ -322,11 +324,7 @@ public class CompositeRolesStore extends AbstractComponent {
}
public void onSecurityIndexHealthChange(ClusterIndexHealth previousHealth, ClusterIndexHealth currentHealth) {
final boolean movedFromRedToNonRed = (previousHealth == null || previousHealth.getStatus() == ClusterHealthStatus.RED)
&& currentHealth != null && currentHealth.getStatus() != ClusterHealthStatus.RED;
final boolean indexDeleted = previousHealth != null && currentHealth == null;
if (movedFromRedToNonRed || indexDeleted) {
if (isMoveFromRedToNonRed(previousHealth, currentHealth) || isIndexDeleted(previousHealth, currentHealth)) {
invalidateAll();
}
}

View File

@ -0,0 +1,211 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.security.authc.support.mapper;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.support.PlainActionFuture;
import org.elasticsearch.client.Client;
import org.elasticsearch.cluster.ClusterName;
import org.elasticsearch.cluster.health.ClusterHealthStatus;
import org.elasticsearch.cluster.health.ClusterIndexHealth;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.env.Environment;
import org.elasticsearch.env.TestEnvironment;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.threadpool.ThreadPool;
import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheAction;
import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheRequest;
import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheResponse;
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
import org.elasticsearch.xpack.core.security.authc.support.mapper.ExpressionRoleMapping;
import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.FieldExpression;
import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.FieldExpression.FieldValue;
import org.elasticsearch.xpack.core.security.user.User;
import org.elasticsearch.xpack.security.SecurityLifecycleService;
import org.elasticsearch.xpack.security.authc.support.CachingUsernamePasswordRealm;
import org.elasticsearch.xpack.security.authc.support.UserRoleMapper;
import org.hamcrest.Matchers;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import static org.elasticsearch.xpack.security.test.SecurityTestUtils.getClusterIndexHealth;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NativeRoleMappingStoreTests extends ESTestCase {
public void testResolveRoles() throws Exception {
// Does match DN
final ExpressionRoleMapping mapping1 = new ExpressionRoleMapping("dept_h",
new FieldExpression("dn", Collections.singletonList(new FieldValue("*,ou=dept_h,o=forces,dc=gc,dc=ca"))),
Arrays.asList("dept_h", "defence"), Collections.emptyMap(), true);
// Does not match - user is not in this group
final ExpressionRoleMapping mapping2 = new ExpressionRoleMapping("admin",
new FieldExpression("groups", Collections.singletonList(
new FieldValue(randomiseDn("cn=esadmin,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")))),
Arrays.asList("admin"), Collections.emptyMap(), true);
// Does match - user is one of these groups
final ExpressionRoleMapping mapping3 = new ExpressionRoleMapping("flight",
new FieldExpression("groups", Arrays.asList(
new FieldValue(randomiseDn("cn=alphaflight,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")),
new FieldValue(randomiseDn("cn=betaflight,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")),
new FieldValue(randomiseDn("cn=gammaflight,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca"))
)),
Arrays.asList("flight"), Collections.emptyMap(), true);
// Does not match - mapping is not enabled
final ExpressionRoleMapping mapping4 = new ExpressionRoleMapping("mutants",
new FieldExpression("groups", Collections.singletonList(
new FieldValue(randomiseDn("cn=mutants,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")))),
Arrays.asList("mutants"), Collections.emptyMap(), false);
final Client client = mock(Client.class);
final SecurityLifecycleService lifecycleService = mock(SecurityLifecycleService.class);
when(lifecycleService.isSecurityIndexAvailable()).thenReturn(true);
final NativeRoleMappingStore store = new NativeRoleMappingStore(Settings.EMPTY, client, lifecycleService) {
@Override
protected void loadMappings(ActionListener<List<ExpressionRoleMapping>> listener) {
final List<ExpressionRoleMapping> mappings = Arrays.asList(mapping1, mapping2, mapping3, mapping4);
logger.info("Role mappings are: [{}]", mappings);
listener.onResponse(mappings);
}
};
final RealmConfig realm = new RealmConfig("ldap1", Settings.EMPTY, Settings.EMPTY, mock(Environment.class),
new ThreadContext(Settings.EMPTY));
final PlainActionFuture<Set<String>> future = new PlainActionFuture<>();
final UserRoleMapper.UserData user = new UserRoleMapper.UserData("sasquatch",
randomiseDn("cn=walter.langowski,ou=people,ou=dept_h,o=forces,dc=gc,dc=ca"),
Arrays.asList(
randomiseDn("cn=alphaflight,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca"),
randomiseDn("cn=mutants,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")
), Collections.emptyMap(), realm);
logger.info("UserData is [{}]", user);
store.resolveRoles(user, future);
final Set<String> roles = future.get();
assertThat(roles, Matchers.containsInAnyOrder("dept_h", "defence", "flight"));
}
private String randomiseDn(String dn) {
// Randomly transform the dn into another valid form that is logically identical,
// but (potentially) textually different
switch (randomIntBetween(0, 3)) {
case 0:
// do nothing
return dn;
case 1:
return dn.toUpperCase(Locale.ROOT);
case 2:
// Upper case just the attribute name for each RDN
return Arrays.stream(dn.split(",")).map(s -> {
final String[] arr = s.split("=");
arr[0] = arr[0].toUpperCase(Locale.ROOT);
return String.join("=", arr);
}).collect(Collectors.joining(","));
case 3:
return dn.replaceAll(",", ", ");
}
return dn;
}
public void testCacheClearOnIndexHealthChange() {
final AtomicInteger numInvalidation = new AtomicInteger(0);
final NativeRoleMappingStore store = buildRoleMappingStoreForInvalidationTesting(numInvalidation);
int expectedInvalidation = 0;
// existing to no longer present
ClusterIndexHealth previousHealth = getClusterIndexHealth(randomFrom(ClusterHealthStatus.GREEN, ClusterHealthStatus.YELLOW));
ClusterIndexHealth currentHealth = null;
store.onSecurityIndexHealthChange(previousHealth, currentHealth);
assertEquals(++expectedInvalidation, numInvalidation.get());
// doesn't exist to exists
previousHealth = null;
currentHealth = getClusterIndexHealth(randomFrom(ClusterHealthStatus.GREEN, ClusterHealthStatus.YELLOW));
store.onSecurityIndexHealthChange(previousHealth, currentHealth);
assertEquals(++expectedInvalidation, numInvalidation.get());
// green or yellow to red
previousHealth = getClusterIndexHealth(randomFrom(ClusterHealthStatus.GREEN, ClusterHealthStatus.YELLOW));
currentHealth = getClusterIndexHealth(ClusterHealthStatus.RED);
store.onSecurityIndexHealthChange(previousHealth, currentHealth);
assertEquals(expectedInvalidation, numInvalidation.get());
// red to non red
previousHealth = getClusterIndexHealth(ClusterHealthStatus.RED);
currentHealth = getClusterIndexHealth(randomFrom(ClusterHealthStatus.GREEN, ClusterHealthStatus.YELLOW));
store.onSecurityIndexHealthChange(previousHealth, currentHealth);
assertEquals(++expectedInvalidation, numInvalidation.get());
// green to yellow or yellow to green
previousHealth = getClusterIndexHealth(randomFrom(ClusterHealthStatus.GREEN, ClusterHealthStatus.YELLOW));
currentHealth = getClusterIndexHealth(
previousHealth.getStatus() == ClusterHealthStatus.GREEN ? ClusterHealthStatus.YELLOW : ClusterHealthStatus.GREEN);
store.onSecurityIndexHealthChange(previousHealth, currentHealth);
assertEquals(expectedInvalidation, numInvalidation.get());
}
public void testCacheClearOnIndexOutOfDateChange() {
final AtomicInteger numInvalidation = new AtomicInteger(0);
final NativeRoleMappingStore store = buildRoleMappingStoreForInvalidationTesting(numInvalidation);
store.onSecurityIndexOutOfDateChange(false, true);
assertEquals(1, numInvalidation.get());
store.onSecurityIndexOutOfDateChange(true, false);
assertEquals(2, numInvalidation.get());
}
private NativeRoleMappingStore buildRoleMappingStoreForInvalidationTesting(AtomicInteger invalidationCounter) {
final Settings settings = Settings.builder().put("path.home", createTempDir()).build();
final ThreadPool threadPool = mock(ThreadPool.class);
final ThreadContext threadContext = new ThreadContext(settings);
when(threadPool.getThreadContext()).thenReturn(threadContext);
final Client client = mock(Client.class);
when(client.threadPool()).thenReturn(threadPool);
when(client.settings()).thenReturn(settings);
doAnswer(invocationOnMock -> {
ActionListener<ClearRealmCacheResponse> listener = (ActionListener<ClearRealmCacheResponse>) invocationOnMock.getArguments()[2];
invalidationCounter.incrementAndGet();
listener.onResponse(new ClearRealmCacheResponse(new ClusterName("cluster"), Collections.emptyList(), Collections.emptyList()));
return null;
}).when(client).execute(eq(ClearRealmCacheAction.INSTANCE), any(ClearRealmCacheRequest.class), any(ActionListener.class));
final Environment env = TestEnvironment.newEnvironment(settings);
final RealmConfig realmConfig = new RealmConfig(getTestName(), Settings.EMPTY, settings, env, threadContext);
final CachingUsernamePasswordRealm mockRealm = new CachingUsernamePasswordRealm("test", realmConfig) {
@Override
protected void doAuthenticate(UsernamePasswordToken token, ActionListener<AuthenticationResult> listener) {
listener.onResponse(AuthenticationResult.notHandled());
}
@Override
protected void doLookupUser(String username, ActionListener<User> listener) {
listener.onResponse(null);
}
};
final NativeRoleMappingStore store = new NativeRoleMappingStore(Settings.EMPTY, client, mock(SecurityLifecycleService.class));
store.refreshRealmOnChange(mockRealm);
return store;
}
}

View File

@ -1,111 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.security.authc.support.mapper;
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.support.PlainActionFuture;
import org.elasticsearch.client.Client;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.env.Environment;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.support.mapper.ExpressionRoleMapping;
import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.FieldExpression;
import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.FieldExpression.FieldValue;
import org.elasticsearch.xpack.security.SecurityLifecycleService;
import org.elasticsearch.xpack.security.authc.support.UserRoleMapper;
import org.hamcrest.Matchers;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NativeUserRoleMapperTests extends ESTestCase {
public void testResolveRoles() throws Exception {
// Does match DN
final ExpressionRoleMapping mapping1 = new ExpressionRoleMapping("dept_h",
new FieldExpression("dn", Collections.singletonList(new FieldValue("*,ou=dept_h,o=forces,dc=gc,dc=ca"))),
Arrays.asList("dept_h", "defence"), Collections.emptyMap(), true);
// Does not match - user is not in this group
final ExpressionRoleMapping mapping2 = new ExpressionRoleMapping("admin",
new FieldExpression("groups", Collections.singletonList(
new FieldValue(randomiseDn("cn=esadmin,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")))),
Arrays.asList("admin"), Collections.emptyMap(), true);
// Does match - user is one of these groups
final ExpressionRoleMapping mapping3 = new ExpressionRoleMapping("flight",
new FieldExpression("groups", Arrays.asList(
new FieldValue(randomiseDn("cn=alphaflight,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")),
new FieldValue(randomiseDn("cn=betaflight,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")),
new FieldValue(randomiseDn("cn=gammaflight,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca"))
)),
Arrays.asList("flight"), Collections.emptyMap(), true);
// Does not match - mapping is not enabled
final ExpressionRoleMapping mapping4 = new ExpressionRoleMapping("mutants",
new FieldExpression("groups", Collections.singletonList(
new FieldValue(randomiseDn("cn=mutants,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")))),
Arrays.asList("mutants"), Collections.emptyMap(), false);
final Client client = mock(Client.class);
final SecurityLifecycleService lifecycleService = mock(SecurityLifecycleService.class);
when(lifecycleService.isSecurityIndexAvailable()).thenReturn(true);
final NativeRoleMappingStore store = new NativeRoleMappingStore(Settings.EMPTY, client, lifecycleService) {
@Override
protected void loadMappings(ActionListener<List<ExpressionRoleMapping>> listener) {
final List<ExpressionRoleMapping> mappings = Arrays.asList(mapping1, mapping2, mapping3, mapping4);
logger.info("Role mappings are: [{}]", mappings);
listener.onResponse(mappings);
}
};
final RealmConfig realm = new RealmConfig("ldap1", Settings.EMPTY, Settings.EMPTY, mock(Environment.class),
new ThreadContext(Settings.EMPTY));
final PlainActionFuture<Set<String>> future = new PlainActionFuture<>();
final UserRoleMapper.UserData user = new UserRoleMapper.UserData("sasquatch",
randomiseDn("cn=walter.langowski,ou=people,ou=dept_h,o=forces,dc=gc,dc=ca"),
Arrays.asList(
randomiseDn("cn=alphaflight,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca"),
randomiseDn("cn=mutants,ou=groups,ou=dept_h,o=forces,dc=gc,dc=ca")
), Collections.emptyMap(), realm);
logger.info("UserData is [{}]", user);
store.resolveRoles(user, future);
final Set<String> roles = future.get();
assertThat(roles, Matchers.containsInAnyOrder("dept_h", "defence", "flight"));
}
private String randomiseDn(String dn) {
// Randomly transform the dn into another valid form that is logically identical,
// but (potentially) textually different
switch (randomIntBetween(0, 3)) {
case 0:
// do nothing
return dn;
case 1:
return dn.toUpperCase(Locale.ROOT);
case 2:
// Upper case just the attribute name for each RDN
return Arrays.stream(dn.split(",")).map(s -> {
final String[] arr = s.split("=");
arr[0] = arr[0].toUpperCase(Locale.ROOT);
return String.join("=", arr);
}).collect(Collectors.joining(","));
case 3:
return dn.replaceAll(",", ", ");
}
return dn;
}
}