blob: 1d538e2cb9343b10e2df139306e758f609d5cc93 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.security;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.invoke.MethodHandles;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.Timer;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.lucene.util.TestUtil;
import org.apache.solr.SolrTestCaseJ4;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.impl.CloudSolrClient;
import org.apache.solr.client.solrj.request.CollectionAdminRequest;
import org.apache.solr.cloud.MiniSolrCloudCluster;
import org.apache.solr.cloud.SolrCloudAuthTestCase;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.params.MapSolrParams;
import org.apache.solr.security.AuditEvent.EventType;
import org.apache.solr.security.AuditEvent.RequestType;
import org.apache.solr.security.AuditLoggerPlugin.JSONAuditEventFormatter;
import org.apache.solr.common.util.SolrNamedThreadFactory;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.apache.solr.client.solrj.request.CollectionAdminRequest.Create;
import static org.apache.solr.client.solrj.request.CollectionAdminRequest.getClusterStatus;
import static org.apache.solr.client.solrj.request.CollectionAdminRequest.getOverseerStatus;
import static org.apache.solr.security.AuditEvent.EventType.COMPLETED;
import static org.apache.solr.security.AuditEvent.EventType.ERROR;
import static org.apache.solr.security.AuditEvent.EventType.REJECTED;
import static org.apache.solr.security.AuditEvent.EventType.UNAUTHORIZED;
import static org.apache.solr.security.AuditEvent.RequestType.ADMIN;
import static org.apache.solr.security.AuditEvent.RequestType.SEARCH;
import static org.apache.solr.security.Sha256AuthenticationProvider.getSaltedHashedValue;
/**
* Validate that audit logging works in a live cluster
*/
@SolrTestCaseJ4.SuppressSSL
public class AuditLoggerIntegrationTest extends SolrCloudAuthTestCase {
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
protected static final JSONAuditEventFormatter formatter = new JSONAuditEventFormatter();
protected static final int NUM_SERVERS = 1;
protected static final int NUM_SHARDS = 1;
protected static final int REPLICATION_FACTOR = 1;
// Use a harness per thread to be able to beast this test
private ThreadLocal<AuditTestHarness> testHarness = new ThreadLocal<>();
@Override
@Before
public void setUp() throws Exception {
super.setUp();
testHarness.set(new AuditTestHarness());
}
@Override
@After
public void tearDown() throws Exception {
if (null != testHarness.get()) {
testHarness.get().close();
}
super.tearDown();
CallbackAuditLoggerPlugin.BLOCKING_SEMAPHORES.clear();
}
@Test
public void testSynchronous() throws Exception {
setupCluster(false, null, false);
runThreeTestAdminCommands();
assertThreeTestAdminEvents();
assertAuditMetricsMinimums(testHarness.get().cluster, CallbackAuditLoggerPlugin.class.getSimpleName(), 3, 0);
}
@Test
public void testAsync() throws Exception {
setupCluster(true, null, false);
runThreeTestAdminCommands();
assertThreeTestAdminEvents();
assertAuditMetricsMinimums(testHarness.get().cluster, CallbackAuditLoggerPlugin.class.getSimpleName(), 3, 0);
}
@Test
public void testQueuedTimeMetric() throws Exception {
final Semaphore gate = new Semaphore(0);
CallbackAuditLoggerPlugin.BLOCKING_SEMAPHORES.put("testQueuedTimeMetric_semaphore", gate);
setupCluster(true, "testQueuedTimeMetric_semaphore", false);
// NOTE: gate is empty, we don't allow any of the events to be logged yet
runThreeTestAdminCommands();
// Don't assume anything about the system clock,
// Thread.sleep is not a garunteed minimum for a predictible elapsed time...
final long start = System.nanoTime();
Thread.sleep(100);
final long end = System.nanoTime();
gate.release(3);
assertThreeTestAdminEvents();
assertAuditMetricsMinimums(testHarness.get().cluster, CallbackAuditLoggerPlugin.class.getSimpleName(), 3, 0);
ArrayList<MetricRegistry> registries = getMetricsReigstries(testHarness.get().cluster);
Timer timer = ((Timer) registries.get(0).getMetrics().get("SECURITY./auditlogging.CallbackAuditLoggerPlugin.queuedTime"));
double meanTimeOnQueue = timer.getSnapshot().getMean();
double meanTimeExpected = (start - end) / 3.0D;
assertTrue("Expecting mean time on queue > "+meanTimeExpected+", got " + meanTimeOnQueue,
meanTimeOnQueue > meanTimeExpected);
}
@Test
public void testAsyncQueueDrain() throws Exception {
final AuditTestHarness harness = testHarness.get();
final Semaphore gate = new Semaphore(0);
CallbackAuditLoggerPlugin.BLOCKING_SEMAPHORES.put("testAsyncQueueDrain_semaphore", gate);
setupCluster(true, "testAsyncQueueDrain_semaphore", false);
final int preShutdownEventsAllowed = TestUtil.nextInt(random(), 0, 2);
final int postShutdownEventsAllowed = 3 - preShutdownEventsAllowed;
// Starting by only allowing 2/3 of the (expected) events to be logged right away...
log.info("Test will allow {} events to happen prior to shutdown", preShutdownEventsAllowed);
gate.release(preShutdownEventsAllowed);
runThreeTestAdminCommands();
final List<AuditEvent> events = new ArrayList<>
(harness.receiver.waitForAuditEvents(preShutdownEventsAllowed));
assertEquals(preShutdownEventsAllowed, events.size());
// Now shutdown cluster while 1 event still in process
// Do this in a background thread because it blocks...
final Thread shutdownThread = new SolrNamedThreadFactory("shutdown")
.newThread(() -> { try {
log.info("START Shutting down Cluster.");
harness.shutdownCluster();
log.info("END Shutting down Cluster.");
} catch (Exception e) {
throw new RuntimeException(e);
}
});
try {
shutdownThread.start();
// release the ticket so the event can be processed
log.info("releasing final {} semaphore tickets...", postShutdownEventsAllowed);
gate.release(postShutdownEventsAllowed);
events.addAll(harness.receiver.waitForAuditEvents(postShutdownEventsAllowed));
assertThreeTestAdminEvents(events);
} finally {
shutdownThread.join();
}
}
@Test
public void testMuteAdminListCollections() throws Exception {
setupCluster(false, null, false, "\"type:UNKNOWN\"", "[ \"path:/admin\", \"param:action=LIST\" ]");
runThreeTestAdminCommands();
testHarness.get().shutdownCluster();
final List<AuditEvent> events = testHarness.get().receiver.waitForAuditEvents(2);
assertEquals(2, events.size()); // sanity check
assertAuditEvent(events.get(0), COMPLETED, "/admin/collections", ADMIN, null, 200,
"action", "CLUSTERSTATUS");
assertAuditEvent(events.get(1), COMPLETED, "/admin/collections", ADMIN, null, 200,
"action", "OVERSEERSTATUS");
}
@Test
public void searchWithException() throws Exception {
setupCluster(false, null, false);
testHarness.get().cluster.getSolrClient().request(CollectionAdminRequest.createCollection("test", 1, 1));
expectThrows(SolrException.class, () -> {
testHarness.get().cluster.getSolrClient().query("test", new MapSolrParams(Collections.singletonMap("q", "a(bc")));
});
final List<AuditEvent> events = testHarness.get().receiver.waitForAuditEvents(3);
assertAuditEvent(events.get(0), COMPLETED, "/admin/cores");
assertAuditEvent(events.get(1), COMPLETED, "/admin/collections");
assertAuditEvent(events.get(2), ERROR,"/select", SEARCH, null, 400);
}
@Test
public void illegalAdminPathError() throws Exception {
setupCluster(false, null, false);
String baseUrl = testHarness.get().cluster.getJettySolrRunner(0).getBaseUrl().toString();
expectThrows(FileNotFoundException.class, () -> {
IOUtils.toString(new URL(baseUrl.replace("/solr", "") + "/api/node/foo"), StandardCharsets.UTF_8);
});
final List<AuditEvent> events = testHarness.get().receiver.waitForAuditEvents(1);
assertAuditEvent(events.get(0), ERROR, "/api/node/foo", ADMIN, null, 404);
}
@Test
public void authValid() throws Exception {
setupCluster(false, null, true);
final CloudSolrClient client = testHarness.get().cluster.getSolrClient();
final CallbackReceiver receiver = testHarness.get().receiver;
{ // valid READ requests: #1 with, and #2 without, (valid) Authentication
final CollectionAdminRequest.List req = new CollectionAdminRequest.List();
// we don't block unknown users for READ, so this should succeed
client.request(req);
// Authenticated user (w/valid password) should also succeed
req.setBasicAuthCredentials("solr", SOLR_PASS);
client.request(req);
final List<AuditEvent> events = receiver.waitForAuditEvents(2);
assertAuditEvent(events.get(0), COMPLETED, "/admin/collections", ADMIN, null, 200, "action", "LIST");
assertAuditEvent(events.get(1), COMPLETED, "/admin/collections", ADMIN, "solr", 200, "action", "LIST");
}
{ // valid CREATE request: Authenticated admin user should be allowed to CREATE collection
final Create req = CollectionAdminRequest.createCollection("test_create", 1, 1);
req.setBasicAuthCredentials("solr", SOLR_PASS);
client.request(req);
// collection createion leads to AuditEvent's for the core as well...
final List<AuditEvent> events = receiver.waitForAuditEvents(2);
assertAuditEvent(events.get(0), COMPLETED, "/admin/cores", ADMIN, null, 200, "action", "CREATE");
assertAuditEvent(events.get(1), COMPLETED, "/admin/collections", ADMIN, null, 200, "action", "CREATE");
}
}
@Test
public void authFailures() throws Exception {
setupCluster(false, null, true);
final CloudSolrClient client = testHarness.get().cluster.getSolrClient();
final CallbackReceiver receiver = testHarness.get().receiver;
{ // invalid request: Authenticated user not allowed to CREATE w/o Authorization
final SolrException e = expectThrows(SolrException.class, () -> {
final Create createRequest = CollectionAdminRequest.createCollection("test_jimbo", 1, 1);
createRequest.setBasicAuthCredentials("jimbo", JIMBO_PASS);
client.request(createRequest);
});
assertEquals(403, e.code());
final List<AuditEvent> events = receiver.waitForAuditEvents(1);
assertAuditEvent(events.get(0), UNAUTHORIZED, "/admin/collections", ADMIN, "jimbo", 403, "name", "test_jimbo");
}
{ // invalid request: Anon user not allowed to CREATE w/o authentication + authorization
final SolrException e = expectThrows(SolrException.class, () -> {
Create createRequest = CollectionAdminRequest.createCollection("test_anon", 1, 1);
client.request(createRequest);
});
assertEquals(401, e.code());
final List<AuditEvent> events = receiver.waitForAuditEvents(1);
assertAuditEvent(events.get(0), REJECTED, "/admin/collections", ADMIN, null, 401, "name", "test_anon");
}
{ // invalid request: Admin user not Authenticated due to incorrect password
final SolrException e = expectThrows(SolrException.class, () -> {
Create createRequest = CollectionAdminRequest.createCollection("test_wrongpass", 1, 1);
createRequest.setBasicAuthCredentials("solr", "wrong_" + SOLR_PASS);
client.request(createRequest);
});
assertEquals(401, e.code());
final List<AuditEvent> events = receiver.waitForAuditEvents(1);
// Event generated from HttpServletRequest. Has no user since auth failed
assertAuditEvent(events.get(0), REJECTED, "/admin/collections", RequestType.ADMIN, null, 401);
}
}
private static void assertAuditEvent(AuditEvent e, EventType type, String path, String... params) {
assertAuditEvent(e, type, path, null, null,null, params);
}
private static void assertAuditEvent(AuditEvent e, EventType type, String path, RequestType requestType, String username, Integer status, String... params) {
try {
assertEquals(type, e.getEventType());
assertEquals(path, e.getResource());
if (requestType != null) {
assertEquals(requestType, e.getRequestType());
}
if (username != null) {
assertEquals(username, e.getUsername());
}
if (status != null) {
assertEquals(status.intValue(), e.getStatus());
}
if (params != null && params.length > 0) {
List<String> p = new LinkedList<>(Arrays.asList(params));
while (p.size() >= 2) {
String val = e.getSolrParamAsString(p.get(0));
assertEquals(p.get(1), val);
p.remove(0);
p.remove(0);
}
}
} catch (AssertionError ae) {
throw new AssertionError(formatter.formatEvent(e) + " => " + ae.getMessage(), ae);
}
}
private ArrayList<MetricRegistry> getMetricsReigstries(MiniSolrCloudCluster cluster) {
ArrayList<MetricRegistry> registries = new ArrayList<>();
cluster.getJettySolrRunners().forEach(r -> {
MetricRegistry registry = r.getCoreContainer().getMetricManager().registry("solr.node");
assertNotNull(registry);
registries.add(registry);
});
return registries;
}
/** @see #assertThreeTestAdminEvents */
private void runThreeTestAdminCommands() throws IOException, SolrServerException {
SolrClient client = testHarness.get().cluster.getSolrClient();
CollectionAdminRequest.listCollections(client);
client.request(getClusterStatus());
client.request(getOverseerStatus());
}
/** @see #runThreeTestAdminCommands */
private void assertThreeTestAdminEvents() throws Exception {
final CallbackReceiver receiver = testHarness.get().receiver;
final List<AuditEvent> events = receiver.waitForAuditEvents(3);
assertThreeTestAdminEvents(events);
}
/** @see #runThreeTestAdminCommands */
private static void assertThreeTestAdminEvents(final List<AuditEvent> events) throws Exception {
assertEquals(3, events.size()); // sanity check
assertAuditEvent(events.get(0), COMPLETED, "/admin/collections", ADMIN, null, 200,
"action", "LIST", "wt", "javabin");
assertAuditEvent(events.get(1), COMPLETED, "/admin/collections", ADMIN, null, 200,
"action", "CLUSTERSTATUS");
assertAuditEvent(events.get(2), COMPLETED, "/admin/collections", ADMIN, null, 200,
"action", "OVERSEERSTATUS");
}
private static String SOLR_PASS = "SolrRocks";
private static String JIMBO_PASS = "JimIsCool";
private static String AUTH_SECTION = ",\n" +
" \"authentication\":{\n" +
" \"blockUnknown\":\"false\",\n" +
" \"class\":\"solr.BasicAuthPlugin\",\n" +
" \"credentials\":{\"solr\":\"" + getSaltedHashedValue(SOLR_PASS) + "\"," +
" \"jimbo\":\"" + getSaltedHashedValue(JIMBO_PASS) + "\"}},\n" +
" \"authorization\":{\n" +
" \"class\":\"solr.RuleBasedAuthorizationPlugin\",\n" +
" \"user-role\":{\"solr\":\"admin\"},\n" +
" \"permissions\":[{\"name\":\"collection-admin-edit\",\"role\":\"admin\"}]\n" +
" }\n";
/**
* Starts the cluster with a security.json built from template, using CallbackAuditLoggerPlugin. The params
* to this method will fill the template.
* @param async enable async audit logging
* @param semaphoreName name of semaphore for controlling how to delay logging
* @param enableAuth should authentication be enabled in this cluster?
* @param muteRulesJson mute rules to trim down what events we care about in our tests
* @throws Exception if anything goes wrong
*/
private void setupCluster(boolean async, String semaphoreName, boolean enableAuth, String... muteRulesJson) throws Exception {
String securityJson = FileUtils.readFileToString(TEST_PATH().resolve("security").resolve("auditlog_plugin_security.json").toFile(), StandardCharsets.UTF_8);
securityJson = securityJson.replace("_PORT_", Integer.toString(testHarness.get().callbackPort));
securityJson = securityJson.replace("_ASYNC_", Boolean.toString(async));
securityJson = securityJson.replace("_SEMAPHORE_",
null == semaphoreName ? "null" : "\""+semaphoreName+"\"");
securityJson = securityJson.replace("_AUTH_", enableAuth ? AUTH_SECTION : "");
// start with any test specific mute rules...
final List<String> muteRules = new ArrayList<>(Arrays.asList(muteRulesJson));
// for test purposes, ignore any intranode /metrics requests...
muteRules.add("\"path:/admin/metrics\"");
// With auth enabled we're also getting /admin/info/key requests
// So for test purposes, we're automatically MUTEing those when auth is enabled...
if (enableAuth) {
muteRules.add("\"path:/admin/info/key\"");
}
securityJson = securityJson.replace("_MUTERULES_", "[" + StringUtils.join(muteRules, ",") + "]");
MiniSolrCloudCluster myCluster = new Builder(NUM_SERVERS, createTempDir())
.withSecurityJson(securityJson)
.addConfig("conf1", TEST_PATH().resolve("configsets").resolve("cloud-minimal").resolve("conf"))
.build();
myCluster.waitForAllNodes(10);
testHarness.get().setCluster(myCluster);
}
/**
* Listening for socket callbacks in background thread from the custom CallbackAuditLoggerPlugin
*/
// we don't really care about the InterruptedException that could be thrown from close in test code
// This all goes back to MiniSolrCloudCluster.close, which really _can_ throw
// an InterruptedException
@SuppressWarnings({"try"})
private class CallbackReceiver implements Runnable, AutoCloseable {
private final ServerSocket serverSocket;
private BlockingQueue<AuditEvent> queue = new LinkedBlockingDeque<>();
CallbackReceiver() throws IOException {
serverSocket = new ServerSocket(0);
}
public int getPort() {
return serverSocket.getLocalPort();
}
@Override
public void run() {
try {
if (log.isInfoEnabled()) {
log.info("Listening for audit callbacks on on port {}", serverSocket.getLocalPort());
}
Socket socket = serverSocket.accept();
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
while (!Thread.currentThread().isInterrupted()) {
if (!reader.ready()) continue;
ObjectMapper om = new ObjectMapper();
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
final String msg = reader.readLine();
final AuditEvent event = om.readValue(msg, AuditEvent.class);
log.info("Received {}: {}", event, msg);
queue.add(event);
}
} catch (IOException e) {
log.info("Socket closed", e);
}
}
@Override
public void close() throws Exception {
serverSocket.close();
assertEquals("Unexpected AuditEvents still in the queue",
Collections.emptyList(), new LinkedList<>(queue));
}
public List<AuditEvent> waitForAuditEvents(final int expected) throws InterruptedException {
final LinkedList<AuditEvent> results = new LinkedList<>();
for (int i = 1; i <= expected; i++) { // NOTE: counting from 1 for error message readabiity...
final AuditEvent e = queue.poll(120, TimeUnit.SECONDS);
if (null == e) {
fail("did not recieved expected event #" + i + "/" + expected
+ " even after waiting an excessive amount of time");
}
log.info("Waited for and recieved event: {}", e);
results.add(e);
}
return results;
}
}
// we don't really care about the InterruptedException that could be thrown from close in test code
// This all goes back to MiniSolrCloudCluster.close, which really _can_ throw
// an InterruptedException
@SuppressWarnings({"try"})
private class AuditTestHarness implements AutoCloseable {
CallbackReceiver receiver;
int callbackPort;
Thread receiverThread;
private MiniSolrCloudCluster cluster;
AuditTestHarness() throws IOException {
receiver = new CallbackReceiver();
callbackPort = receiver.getPort();
receiverThread = new SolrNamedThreadFactory("auditTestCallback").newThread(receiver);
receiverThread.start();
}
@Override
public void close() throws Exception {
shutdownCluster();
receiverThread.interrupt();
receiver.close();
receiverThread = null;
}
public void shutdownCluster() throws Exception {
if (cluster != null) cluster.shutdown();
}
public void setCluster(MiniSolrCloudCluster cluster) {
this.cluster = cluster;
}
}
}