diff --git a/plugin/src/main/java/org/elasticsearch/xpack/common/IteratingActionListener.java b/plugin/src/main/java/org/elasticsearch/xpack/common/IteratingActionListener.java index cc690c19ec1..8c25f4e2604 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/common/IteratingActionListener.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/common/IteratingActionListener.java @@ -6,11 +6,13 @@ package org.elasticsearch.xpack.common; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.inject.internal.Nullable; import org.elasticsearch.common.util.concurrent.ThreadContext; import java.util.Collections; import java.util.List; import java.util.function.BiConsumer; +import java.util.function.Supplier; /** * This action listener wraps another listener and provides a framework for iteration over a List while calling an asynchronous function @@ -19,7 +21,8 @@ import java.util.function.BiConsumer; * listener's {@link ActionListener#onFailure(Exception)} method is called. If the consumer calls {@link #onResponse(Object)} with a * non-null object, iteration will cease and the wrapped listener will be called with the response. In the case of a null value being passed * to {@link #onResponse(Object)} then iteration will continue by applying the {@link BiConsumer} to the next item in the list; if the list - * has no more elements then the wrapped listener will be called with a null value. + * has no more elements then the wrapped listener will be called with a null value, unless an optional {@link Supplier} is provided + * that supplies the response to send for {@link ActionListener#onResponse(Object)}. * * After creation, iteration is started by calling {@link #run()} */ @@ -29,15 +32,42 @@ public final class IteratingActionListener implements ActionListener, R private final ActionListener delegate; private final BiConsumer> consumer; private final ThreadContext threadContext; + private final Supplier consumablesFinishedResponse; private int position = 0; + /** + * Constructs an {@link IteratingActionListener}. + * + * @param delegate the delegate listener to call when all consumables have finished executing + * @param consumer the consumer that is executed for each consumable instance + * @param consumables the instances that can be consumed to produce a response which is ultimately sent on the delegate listener + * @param threadContext the thread context for the thread pool that created the listener + */ public IteratingActionListener(ActionListener delegate, BiConsumer> consumer, List consumables, ThreadContext threadContext) { + this(delegate, consumer, consumables, threadContext, null); + } + + /** + * Constructs an {@link IteratingActionListener}. + * + * @param delegate the delegate listener to call when all consumables have finished executing + * @param consumer the consumer that is executed for each consumable instance + * @param consumables the instances that can be consumed to produce a response which is ultimately sent on the delegate listener + * @param threadContext the thread context for the thread pool that created the listener + * @param consumablesFinishedResponse a supplier that maps the last consumable's response to a response + * to be sent on the delegate listener, in case the last consumable returns a + * {@code null} value, but the delegate listener should respond with some other value + * (perhaps a concatenation of the results of all the consumables). + */ + public IteratingActionListener(ActionListener delegate, BiConsumer> consumer, List consumables, + ThreadContext threadContext, @Nullable Supplier consumablesFinishedResponse) { this.delegate = delegate; this.consumer = consumer; this.consumables = Collections.unmodifiableList(consumables); this.threadContext = threadContext; + this.consumablesFinishedResponse = consumablesFinishedResponse; } @Override @@ -60,7 +90,11 @@ public final class IteratingActionListener implements ActionListener, R try (ThreadContext.StoredContext ignore = threadContext.newStoredContext(false)) { if (response == null) { if (position == consumables.size()) { - delegate.onResponse(null); + if (consumablesFinishedResponse != null) { + delegate.onResponse(consumablesFinishedResponse.get()); + } else { + delegate.onResponse(null); + } } else { consumer.accept(consumables.get(position++), this); } diff --git a/plugin/src/main/java/org/elasticsearch/xpack/extensions/XPackExtension.java b/plugin/src/main/java/org/elasticsearch/xpack/extensions/XPackExtension.java index ff83d1aa845..c84de68b040 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/extensions/XPackExtension.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/extensions/XPackExtension.java @@ -10,12 +10,16 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.BiConsumer; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.xpack.security.authc.AuthenticationFailureHandler; import org.elasticsearch.xpack.security.authc.Realm; import org.elasticsearch.xpack.security.authc.RealmConfig; +import org.elasticsearch.xpack.security.authz.RoleDescriptor; /** @@ -82,4 +86,29 @@ public abstract class XPackExtension { public List getSettingsFilter() { return Collections.emptyList(); } + + /** + * Returns an ordered list of role providers that are used to resolve role names + * to {@link RoleDescriptor} objects. Each provider is invoked in order to + * resolve any role names not resolved by the reserved or native roles stores. + * + * Each role provider is represented as a {@link BiConsumer} which takes a set + * of roles to resolve as the first parameter to consume and an {@link ActionListener} + * as the second parameter to consume. The implementation of the role provider + * should be asynchronous if the computation is lengthy or any disk and/or network + * I/O is involved. The implementation is responsible for resolving whatever roles + * it can into a set of {@link RoleDescriptor} instances. If successful, the + * implementation must invoke {@link ActionListener#onResponse(Object)} to pass along + * the resolved set of role descriptors. If a failure was encountered, the + * implementation must invoke {@link ActionListener#onFailure(Exception)}. + * + * By default, an empty list is returned. + * + * @param settings The configured settings for the node + * @param resourceWatcherService Use to watch configuration files for changes + */ + public List, ActionListener>>> + getRolesProviders(Settings settings, ResourceWatcherService resourceWatcherService) { + return Collections.emptyList(); + } } diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java b/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java index 605e32b719d..20c516dfb83 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.security; import org.apache.logging.log4j.Logger; import org.apache.lucene.util.SetOnce; +import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ActionRequest; import org.elasticsearch.action.ActionResponse; import org.elasticsearch.action.support.ActionFilter; @@ -100,6 +101,7 @@ import org.elasticsearch.xpack.security.authc.ldap.support.SessionFactory; import org.elasticsearch.xpack.security.authc.support.SecuredString; import org.elasticsearch.xpack.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.security.authz.AuthorizationService; +import org.elasticsearch.xpack.security.authz.RoleDescriptor; import org.elasticsearch.xpack.security.authz.accesscontrol.OptOutQueryCache; import org.elasticsearch.xpack.security.authz.accesscontrol.SecurityIndexSearcherWrapper; import org.elasticsearch.xpack.security.authz.accesscontrol.SetSecurityUserProcessor; @@ -143,6 +145,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.Supplier; import java.util.function.UnaryOperator; @@ -329,8 +332,12 @@ public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin { final FileRolesStore fileRolesStore = new FileRolesStore(settings, env, resourceWatcherService, licenseState); final NativeRolesStore nativeRolesStore = new NativeRolesStore(settings, client, licenseState, securityLifecycleService); final ReservedRolesStore reservedRolesStore = new ReservedRolesStore(); - final CompositeRolesStore allRolesStore = - new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, reservedRolesStore, licenseState); + List, ActionListener>>> rolesProviders = new ArrayList<>(); + for (XPackExtension extension : extensions) { + rolesProviders.addAll(extension.getRolesProviders(settings, resourceWatcherService)); + } + final CompositeRolesStore allRolesStore = new CompositeRolesStore(settings, fileRolesStore, nativeRolesStore, + reservedRolesStore, rolesProviders, threadPool.getThreadContext(), licenseState); // to keep things simple, just invalidate all cached entries on license change. this happens so rarely that the impact should be // minimal licenseState.addListener(allRolesStore::invalidateAll); diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java b/plugin/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java index bbdf5bc44b7..161c42152b9 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStore.java @@ -15,10 +15,12 @@ import org.elasticsearch.common.inject.internal.Nullable; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Setting.Property; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.common.util.concurrent.ConcurrentCollections; import org.elasticsearch.common.util.concurrent.ReleasableLock; import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.xpack.common.IteratingActionListener; import org.elasticsearch.xpack.security.authz.RoleDescriptor; import org.elasticsearch.xpack.security.authz.RoleDescriptor.IndicesPrivileges; import org.elasticsearch.xpack.security.authz.permission.FieldPermissionsCache; @@ -32,12 +34,14 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BiConsumer; import java.util.stream.Collectors; import static org.elasticsearch.xpack.security.Security.setting; @@ -70,10 +74,15 @@ public class CompositeRolesStore extends AbstractComponent { private final XPackLicenseState licenseState; private final Cache, Role> roleCache; private final Set negativeLookupCache; + private final ThreadContext threadContext; private final AtomicLong numInvalidation = new AtomicLong(); + private final List, ActionListener>>> customRolesProviders; public CompositeRolesStore(Settings settings, FileRolesStore fileRolesStore, NativeRolesStore nativeRolesStore, - ReservedRolesStore reservedRolesStore, XPackLicenseState licenseState) { + ReservedRolesStore reservedRolesStore, + List, ActionListener>>> rolesProviders, + ThreadContext threadContext, + XPackLicenseState licenseState) { super(settings); this.fileRolesStore = fileRolesStore; // invalidating all on a file based role update is heavy handed to say the least, but in general this should be infrequent so the @@ -88,7 +97,9 @@ public class CompositeRolesStore extends AbstractComponent { builder.setMaximumWeight(cacheSize); } this.roleCache = builder.build(); + this.threadContext = threadContext; this.negativeLookupCache = ConcurrentCollections.newConcurrentSet(); + this.customRolesProviders = Collections.unmodifiableList(rolesProviders); } public void roles(Set roleNames, FieldPermissionsCache fieldPermissionsCache, ActionListener roleActionListener) { @@ -141,9 +152,34 @@ public class CompositeRolesStore extends AbstractComponent { if (builtInRoleDescriptors.size() != filteredRoleNames.size()) { final Set missing = difference(filteredRoleNames, builtInRoleDescriptors); assert missing.isEmpty() == false : "the missing set should not be empty if the sizes didn't match"; - negativeLookupCache.addAll(missing); + if (!customRolesProviders.isEmpty()) { + new IteratingActionListener<>(roleDescriptorActionListener, (rolesProvider, listener) -> { + // resolve descriptors with role provider + rolesProvider.accept(missing, ActionListener.wrap((resolvedDescriptors) -> { + builtInRoleDescriptors.addAll(resolvedDescriptors); + // remove resolved descriptors from the set of roles still needed to be resolved + for (RoleDescriptor descriptor : resolvedDescriptors) { + missing.remove(descriptor.getName()); + } + if (missing.isEmpty()) { + // no more roles to resolve, send the response + listener.onResponse(Collections.unmodifiableSet(builtInRoleDescriptors)); + } else { + // still have roles to resolve, keep trying with the next roles provider + listener.onResponse(null); + } + }, listener::onFailure)); + }, customRolesProviders, threadContext, () -> { + negativeLookupCache.addAll(missing); + return builtInRoleDescriptors; + }).run(); + } else { + negativeLookupCache.addAll(missing); + roleDescriptorActionListener.onResponse(Collections.unmodifiableSet(builtInRoleDescriptors)); + } + } else { + roleDescriptorActionListener.onResponse(Collections.unmodifiableSet(builtInRoleDescriptors)); } - roleDescriptorActionListener.onResponse(Collections.unmodifiableSet(builtInRoleDescriptors)); }, roleDescriptorActionListener::onFailure)); } } diff --git a/plugin/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java b/plugin/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java index ecb7111df93..6baade5266d 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/security/authz/store/CompositeRolesStoreTests.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.security.authz.store; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.common.util.set.Sets; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.license.XPackLicenseState; @@ -16,12 +17,20 @@ import org.elasticsearch.xpack.security.authz.RoleDescriptor.IndicesPrivileges; import org.elasticsearch.xpack.security.authz.permission.Role; import org.elasticsearch.xpack.security.authz.RoleDescriptor; import org.elasticsearch.xpack.security.authz.permission.FieldPermissionsCache; +import org.elasticsearch.xpack.security.authz.privilege.IndexPrivilege; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.function.BiConsumer; +import java.util.function.Function; import static org.elasticsearch.mock.orig.Mockito.times; import static org.elasticsearch.mock.orig.Mockito.verifyNoMoreInteractions; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anySetOf; import static org.mockito.Matchers.eq; @@ -73,7 +82,7 @@ public class CompositeRolesStoreTests extends ESTestCase { when(fileRolesStore.roleDescriptors(Collections.singleton("fls_dls"))).thenReturn(Collections.singleton(flsDlsRole)); when(fileRolesStore.roleDescriptors(Collections.singleton("no_fls_dls"))).thenReturn(Collections.singleton(noFlsDlsRole)); CompositeRolesStore compositeRolesStore = new CompositeRolesStore(Settings.EMPTY, fileRolesStore, mock(NativeRolesStore.class), - mock(ReservedRolesStore.class), licenseState); + mock(ReservedRolesStore.class), Collections.emptyList(), new ThreadContext(Settings.EMPTY), licenseState); FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); PlainActionFuture roleFuture = new PlainActionFuture<>(); @@ -132,7 +141,7 @@ public class CompositeRolesStoreTests extends ESTestCase { when(fileRolesStore.roleDescriptors(Collections.singleton("fls_dls"))).thenReturn(Collections.singleton(flsDlsRole)); when(fileRolesStore.roleDescriptors(Collections.singleton("no_fls_dls"))).thenReturn(Collections.singleton(noFlsDlsRole)); CompositeRolesStore compositeRolesStore = new CompositeRolesStore(Settings.EMPTY, fileRolesStore, mock(NativeRolesStore.class), - mock(ReservedRolesStore.class), licenseState); + mock(ReservedRolesStore.class), Collections.emptyList(), new ThreadContext(Settings.EMPTY), licenseState); FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); PlainActionFuture roleFuture = new PlainActionFuture<>(); @@ -164,7 +173,8 @@ public class CompositeRolesStoreTests extends ESTestCase { final ReservedRolesStore reservedRolesStore = spy(new ReservedRolesStore()); final CompositeRolesStore compositeRolesStore = - new CompositeRolesStore(Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, new XPackLicenseState()); + new CompositeRolesStore(Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, + Collections.emptyList(), new ThreadContext(Settings.EMPTY), new XPackLicenseState()); verify(fileRolesStore).addListener(any(Runnable.class)); // adds a listener in ctor final String roleName = randomAsciiOfLengthBetween(1, 10); @@ -193,4 +203,132 @@ public class CompositeRolesStoreTests extends ESTestCase { } verifyNoMoreInteractions(fileRolesStore, reservedRolesStore, nativeRolesStore); } + + public void testCustomRolesProviders() { + final FileRolesStore fileRolesStore = mock(FileRolesStore.class); + when(fileRolesStore.roleDescriptors(anySetOf(String.class))).thenReturn(Collections.emptySet()); + final NativeRolesStore nativeRolesStore = mock(NativeRolesStore.class); + doAnswer((invocationOnMock) -> { + ActionListener> callback = (ActionListener>) invocationOnMock.getArguments()[1]; + callback.onResponse(Collections.emptySet()); + return null; + }).when(nativeRolesStore).getRoleDescriptors(isA(String[].class), any(ActionListener.class)); + final ReservedRolesStore reservedRolesStore = spy(new ReservedRolesStore()); + + final InMemoryRolesProvider inMemoryProvider1 = spy(new InMemoryRolesProvider((roles) -> { + Set descriptors = new HashSet<>(); + if (roles.contains("roleA")) { + descriptors.add(new RoleDescriptor("roleA", null, + new IndicesPrivileges[] { + IndicesPrivileges.builder().privileges("READ").indices("foo").grantedFields("*").build() + }, null)); + } + return descriptors; + })); + + final InMemoryRolesProvider inMemoryProvider2 = spy(new InMemoryRolesProvider((roles) -> { + Set descriptors = new HashSet<>(); + if (roles.contains("roleA")) { + // both role providers can resolve role A, this makes sure that if the first + // role provider in order resolves a role, the second provider does not override it + descriptors.add(new RoleDescriptor("roleA", null, + new IndicesPrivileges[] { + IndicesPrivileges.builder().privileges("WRITE").indices("*").grantedFields("*").build() + }, null)); + } + if (roles.contains("roleB")) { + descriptors.add(new RoleDescriptor("roleB", null, + new IndicesPrivileges[] { + IndicesPrivileges.builder().privileges("READ").indices("bar").grantedFields("*").build() + }, null)); + } + return descriptors; + })); + + final CompositeRolesStore compositeRolesStore = + new CompositeRolesStore(Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, + Arrays.asList(inMemoryProvider1, inMemoryProvider2), new ThreadContext(Settings.EMPTY), + new XPackLicenseState()); + + final Set roleNames = Sets.newHashSet("roleA", "roleB", "unknown"); + PlainActionFuture future = new PlainActionFuture<>(); + final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); + compositeRolesStore.roles(roleNames, fieldPermissionsCache, future); + final Role role = future.actionGet(); + + // make sure custom roles providers populate roles correctly + assertEquals(2, role.indices().groups().length); + assertEquals(IndexPrivilege.READ, role.indices().groups()[0].privilege()); + assertThat(role.indices().groups()[0].indices()[0], anyOf(equalTo("foo"), equalTo("bar"))); + assertEquals(IndexPrivilege.READ, role.indices().groups()[1].privilege()); + assertThat(role.indices().groups()[1].indices()[0], anyOf(equalTo("foo"), equalTo("bar"))); + + // make sure negative lookups are cached + verify(inMemoryProvider1).accept(anySetOf(String.class), any(ActionListener.class)); + verify(inMemoryProvider2).accept(anySetOf(String.class), any(ActionListener.class)); + + final int numberOfTimesToCall = scaledRandomIntBetween(1, 8); + for (int i = 0; i < numberOfTimesToCall; i++) { + future = new PlainActionFuture<>(); + compositeRolesStore.roles(Collections.singleton("unknown"), fieldPermissionsCache, future); + future.actionGet(); + } + + verifyNoMoreInteractions(inMemoryProvider1, inMemoryProvider2); + } + + public void testCustomRolesProviderFailures() throws Exception { + final FileRolesStore fileRolesStore = mock(FileRolesStore.class); + when(fileRolesStore.roleDescriptors(anySetOf(String.class))).thenReturn(Collections.emptySet()); + final NativeRolesStore nativeRolesStore = mock(NativeRolesStore.class); + doAnswer((invocationOnMock) -> { + ActionListener> callback = (ActionListener>) invocationOnMock.getArguments()[1]; + callback.onResponse(Collections.emptySet()); + return null; + }).when(nativeRolesStore).getRoleDescriptors(isA(String[].class), any(ActionListener.class)); + final ReservedRolesStore reservedRolesStore = new ReservedRolesStore(); + + final InMemoryRolesProvider inMemoryProvider1 = new InMemoryRolesProvider((roles) -> { + Set descriptors = new HashSet<>(); + if (roles.contains("roleA")) { + descriptors.add(new RoleDescriptor("roleA", null, + new IndicesPrivileges[] { + IndicesPrivileges.builder().privileges("READ").indices("foo").grantedFields("*").build() + }, null)); + } + return descriptors; + }); + + final BiConsumer, ActionListener>> failingProvider = + (roles, listener) -> listener.onFailure(new Exception("fake failure")); + + final CompositeRolesStore compositeRolesStore = + new CompositeRolesStore(Settings.EMPTY, fileRolesStore, nativeRolesStore, reservedRolesStore, + Arrays.asList(inMemoryProvider1, failingProvider), new ThreadContext(Settings.EMPTY), + new XPackLicenseState()); + + final Set roleNames = Sets.newHashSet("roleA", "roleB", "unknown"); + PlainActionFuture future = new PlainActionFuture<>(); + final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY); + compositeRolesStore.roles(roleNames, fieldPermissionsCache, future); + try { + future.get(); + fail("provider should have thrown a failure"); + } catch (ExecutionException e) { + assertEquals("fake failure", e.getCause().getMessage()); + } + } + + private static class InMemoryRolesProvider implements BiConsumer, ActionListener>> { + private final Function, Set> roleDescriptorsFunc; + + InMemoryRolesProvider(Function, Set> roleDescriptorsFunc) { + this.roleDescriptorsFunc = roleDescriptorsFunc; + } + + @Override + public void accept(Set roles, ActionListener> listener) { + listener.onResponse(roleDescriptorsFunc.apply(roles)); + } + } } diff --git a/qa/security-example-realm/build.gradle b/qa/security-example-extension/build.gradle similarity index 95% rename from qa/security-example-realm/build.gradle rename to qa/security-example-extension/build.gradle index 0dbe76d0f79..35e2278cf88 100644 --- a/qa/security-example-realm/build.gradle +++ b/qa/security-example-extension/build.gradle @@ -57,6 +57,8 @@ integTestCluster { setting 'xpack.security.authc.realms.custom.filtered_setting', 'should be filtered' setting 'xpack.security.authc.realms.esusers.order', '1' setting 'xpack.security.authc.realms.esusers.type', 'file' + setting 'xpack.security.authc.realms.native.type', 'native' + setting 'xpack.security.authc.realms.native.order', '2' setupCommand 'setupDummyUser', 'bin/x-pack/users', 'useradd', 'test_user', '-p', 'changeme', '-r', 'superuser' diff --git a/qa/security-example-realm/src/main/java/org/elasticsearch/example/ExampleRealmExtension.java b/qa/security-example-extension/src/main/java/org/elasticsearch/example/ExampleExtension.java similarity index 62% rename from qa/security-example-realm/src/main/java/org/elasticsearch/example/ExampleRealmExtension.java rename to qa/security-example-extension/src/main/java/org/elasticsearch/example/ExampleExtension.java index 49688c1f2ba..5e80b2865ab 100644 --- a/qa/security-example-realm/src/main/java/org/elasticsearch/example/ExampleRealmExtension.java +++ b/qa/security-example-extension/src/main/java/org/elasticsearch/example/ExampleExtension.java @@ -5,22 +5,35 @@ */ package org.elasticsearch.example; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.example.realm.CustomAuthenticationFailureHandler; import org.elasticsearch.example.realm.CustomRealm; +import org.elasticsearch.example.role.CustomInMemoryRolesProvider; import org.elasticsearch.watcher.ResourceWatcherService; import org.elasticsearch.xpack.security.authc.AuthenticationFailureHandler; import org.elasticsearch.xpack.extensions.XPackExtension; import org.elasticsearch.xpack.security.authc.Realm; +import org.elasticsearch.xpack.security.authz.RoleDescriptor; import java.security.AccessController; import java.security.PrivilegedAction; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; -public class ExampleRealmExtension extends XPackExtension { +import static org.elasticsearch.example.role.CustomInMemoryRolesProvider.ROLE_A; +import static org.elasticsearch.example.role.CustomInMemoryRolesProvider.ROLE_B; + +/** + * An example x-pack extension for testing custom realms and custom role providers. + */ +public class ExampleExtension extends XPackExtension { static { // check that the extension's policy works. @@ -59,4 +72,15 @@ public class ExampleRealmExtension extends XPackExtension { public List getSettingsFilter() { return Collections.singletonList("xpack.security.authc.realms.*.filtered_setting"); } + + @Override + public List, ActionListener>>> + getRolesProviders(Settings settings, ResourceWatcherService resourceWatcherService) { + CustomInMemoryRolesProvider rp1 = new CustomInMemoryRolesProvider(settings, Collections.singletonMap(ROLE_A, "read")); + Map roles = new HashMap<>(); + roles.put(ROLE_A, "all"); + roles.put(ROLE_B, "all"); + CustomInMemoryRolesProvider rp2 = new CustomInMemoryRolesProvider(settings, roles); + return Arrays.asList(rp1, rp2); + } } diff --git a/qa/security-example-realm/src/main/java/org/elasticsearch/example/realm/CustomAuthenticationFailureHandler.java b/qa/security-example-extension/src/main/java/org/elasticsearch/example/realm/CustomAuthenticationFailureHandler.java similarity index 100% rename from qa/security-example-realm/src/main/java/org/elasticsearch/example/realm/CustomAuthenticationFailureHandler.java rename to qa/security-example-extension/src/main/java/org/elasticsearch/example/realm/CustomAuthenticationFailureHandler.java diff --git a/qa/security-example-realm/src/main/java/org/elasticsearch/example/realm/CustomRealm.java b/qa/security-example-extension/src/main/java/org/elasticsearch/example/realm/CustomRealm.java similarity index 95% rename from qa/security-example-realm/src/main/java/org/elasticsearch/example/realm/CustomRealm.java rename to qa/security-example-extension/src/main/java/org/elasticsearch/example/realm/CustomRealm.java index a8aae73f733..bbd37aa7e11 100644 --- a/qa/security-example-realm/src/main/java/org/elasticsearch/example/realm/CustomRealm.java +++ b/qa/security-example-extension/src/main/java/org/elasticsearch/example/realm/CustomRealm.java @@ -21,8 +21,8 @@ public class CustomRealm extends Realm { public static final String USER_HEADER = "User"; public static final String PW_HEADER = "Password"; - static final String KNOWN_USER = "custom_user"; - static final String KNOWN_PW = "changeme"; + public static final String KNOWN_USER = "custom_user"; + public static final String KNOWN_PW = "changeme"; static final String[] ROLES = new String[] { "superuser" }; public CustomRealm(RealmConfig config) { diff --git a/qa/security-example-extension/src/main/java/org/elasticsearch/example/role/CustomInMemoryRolesProvider.java b/qa/security-example-extension/src/main/java/org/elasticsearch/example/role/CustomInMemoryRolesProvider.java new file mode 100644 index 00000000000..e1f4680c6c9 --- /dev/null +++ b/qa/security-example-extension/src/main/java/org/elasticsearch/example/role/CustomInMemoryRolesProvider.java @@ -0,0 +1,57 @@ +/* + * 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.example.role; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.component.AbstractComponent; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.xpack.security.authz.RoleDescriptor; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +/** + * A custom roles provider implementation for testing that serves + * static roles from memory. + */ +public class CustomInMemoryRolesProvider + extends AbstractComponent + implements BiConsumer, ActionListener>> { + + public static final String INDEX = "foo"; + public static final String ROLE_A = "roleA"; + public static final String ROLE_B = "roleB"; + + private final Map rolePermissionSettings; + + public CustomInMemoryRolesProvider(Settings settings, Map rolePermissionSettings) { + super(settings); + this.rolePermissionSettings = rolePermissionSettings; + } + + @Override + public void accept(Set roles, ActionListener> listener) { + Set roleDescriptors = new HashSet<>(); + for (String role : roles) { + if (rolePermissionSettings.containsKey(role)) { + roleDescriptors.add( + new RoleDescriptor(role, new String[] { "all" }, + new RoleDescriptor.IndicesPrivileges[] { + RoleDescriptor.IndicesPrivileges.builder() + .privileges(rolePermissionSettings.get(role)) + .indices(INDEX) + .grantedFields("*") + .build() + }, null) + ); + } + } + + listener.onResponse(roleDescriptors); + } +} diff --git a/qa/security-example-extension/src/main/resources/x-pack-extension-descriptor.properties b/qa/security-example-extension/src/main/resources/x-pack-extension-descriptor.properties new file mode 100644 index 00000000000..a849b42c24d --- /dev/null +++ b/qa/security-example-extension/src/main/resources/x-pack-extension-descriptor.properties @@ -0,0 +1,6 @@ +description=Custom Extension +version=${version} +name=exampleextension +classname=org.elasticsearch.example.ExampleExtension +java.version=${java.version} +xpack.version=${xpack.version} \ No newline at end of file diff --git a/qa/security-example-realm/src/main/resources/x-pack-extension-security.policy b/qa/security-example-extension/src/main/resources/x-pack-extension-security.policy similarity index 100% rename from qa/security-example-realm/src/main/resources/x-pack-extension-security.policy rename to qa/security-example-extension/src/main/resources/x-pack-extension-security.policy diff --git a/qa/security-example-realm/src/test/java/org/elasticsearch/example/realm/CustomRealmIT.java b/qa/security-example-extension/src/test/java/org/elasticsearch/example/realm/CustomRealmIT.java similarity index 100% rename from qa/security-example-realm/src/test/java/org/elasticsearch/example/realm/CustomRealmIT.java rename to qa/security-example-extension/src/test/java/org/elasticsearch/example/realm/CustomRealmIT.java diff --git a/qa/security-example-realm/src/test/java/org/elasticsearch/example/realm/CustomRealmTests.java b/qa/security-example-extension/src/test/java/org/elasticsearch/example/realm/CustomRealmTests.java similarity index 100% rename from qa/security-example-realm/src/test/java/org/elasticsearch/example/realm/CustomRealmTests.java rename to qa/security-example-extension/src/test/java/org/elasticsearch/example/realm/CustomRealmTests.java diff --git a/qa/security-example-extension/src/test/java/org/elasticsearch/example/role/CustomRolesProviderIT.java b/qa/security-example-extension/src/test/java/org/elasticsearch/example/role/CustomRolesProviderIT.java new file mode 100644 index 00000000000..1291404a0a2 --- /dev/null +++ b/qa/security-example-extension/src/test/java/org/elasticsearch/example/role/CustomRolesProviderIT.java @@ -0,0 +1,95 @@ +/* + * 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.example.role; + +import org.apache.http.message.BasicHeader; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.ResponseException; +import org.elasticsearch.common.network.NetworkModule; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.example.realm.CustomRealm; +import org.elasticsearch.plugins.Plugin; +import org.elasticsearch.test.ESIntegTestCase; +import org.elasticsearch.xpack.XPackPlugin; +import org.elasticsearch.xpack.security.authc.support.SecuredString; +import org.elasticsearch.xpack.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.security.client.SecurityClient; + +import java.util.Collection; +import java.util.Collections; + +import static org.elasticsearch.example.role.CustomInMemoryRolesProvider.INDEX; +import static org.elasticsearch.example.role.CustomInMemoryRolesProvider.ROLE_A; +import static org.elasticsearch.example.role.CustomInMemoryRolesProvider.ROLE_B; +import static org.elasticsearch.xpack.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; +import static org.hamcrest.Matchers.is; + +/** + * Integration test for custom roles providers. + */ +public class CustomRolesProviderIT extends ESIntegTestCase { + + private static final String TEST_USER = "test_user"; + private static final String TEST_PWD = "change_me"; + + @Override + protected Settings externalClusterClientSettings() { + return Settings.builder() + .put(ThreadContext.PREFIX + "." + CustomRealm.USER_HEADER, CustomRealm.KNOWN_USER) + .put(ThreadContext.PREFIX + "." + CustomRealm.PW_HEADER, CustomRealm.KNOWN_PW) + .put(NetworkModule.TRANSPORT_TYPE_KEY, "security4") + .build(); + } + + @Override + protected Collection> transportClientPlugins() { + return Collections.singleton(XPackPlugin.class); + } + + public void setupTestUser(String role) { + SecurityClient securityClient = new SecurityClient(client()); + securityClient.preparePutUser(TEST_USER, TEST_PWD.toCharArray(), role).get(); + } + + public void testAuthorizedCustomRoleSucceeds() throws Exception { + setupTestUser(ROLE_B); + // roleB has all permissions on index "foo", so creating "foo" should succeed + Response response = getRestClient().performRequest("PUT", "/" + INDEX, authHeader()); + assertThat(response.getStatusLine().getStatusCode(), is(200)); + } + + public void testFirstResolvedRoleTakesPrecedence() throws Exception { + // the first custom roles provider has set ROLE_A to only have read permission on the index, + // the second custom roles provider has set ROLE_A to have all permissions, but since + // the first custom role provider appears first in order, it should take precedence and deny + // permission to create the index + setupTestUser(ROLE_A); + // roleB has all permissions on index "foo", so creating "foo" should succeed + try { + getRestClient().performRequest("PUT", "/" + INDEX, authHeader()); + fail(ROLE_A + " should not be authorized to create index " + INDEX); + } catch (ResponseException e) { + assertThat(e.getResponse().getStatusLine().getStatusCode(), is(403)); + } + } + + public void testUnresolvedRoleDoesntSucceed() throws Exception { + setupTestUser("unknown"); + // roleB has all permissions on index "foo", so creating "foo" should succeed + try { + getRestClient().performRequest("PUT", "/" + INDEX, authHeader()); + fail(ROLE_A + " should not be authorized to create index " + INDEX); + } catch (ResponseException e) { + assertThat(e.getResponse().getStatusLine().getStatusCode(), is(403)); + } + } + + private BasicHeader authHeader() { + return new BasicHeader(UsernamePasswordToken.BASIC_AUTH_HEADER, + basicAuthHeaderValue(TEST_USER, new SecuredString(TEST_PWD.toCharArray()))); + } +} diff --git a/qa/security-example-realm/src/main/resources/x-pack-extension-descriptor.properties b/qa/security-example-realm/src/main/resources/x-pack-extension-descriptor.properties deleted file mode 100644 index faa674a8b49..00000000000 --- a/qa/security-example-realm/src/main/resources/x-pack-extension-descriptor.properties +++ /dev/null @@ -1,6 +0,0 @@ -description=Custom Realm Extension -version=${version} -name=examplerealm -classname=org.elasticsearch.example.ExampleRealmExtension -java.version=${java.version} -xpack.version=${xpack.version} \ No newline at end of file