| /* |
| * 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; |
| } |
| } |
| } |