001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.http;
019
020import static org.junit.jupiter.api.Assertions.assertEquals;
021import static org.junit.jupiter.api.Assertions.assertFalse;
022import static org.junit.jupiter.api.Assertions.assertTrue;
023
024import java.io.File;
025import java.lang.management.ManagementFactory;
026import java.net.HttpURLConnection;
027import java.net.URL;
028import java.security.PrivilegedExceptionAction;
029import javax.management.ObjectName;
030import org.apache.hadoop.conf.Configuration;
031import org.apache.hadoop.fs.CommonConfigurationKeys;
032import org.apache.hadoop.fs.Path;
033import org.apache.hadoop.hbase.HBaseTestingUtil;
034import org.apache.hadoop.hbase.HConstants;
035import org.apache.hadoop.hbase.LocalHBaseCluster;
036import org.apache.hadoop.hbase.TableName;
037import org.apache.hadoop.hbase.Waiter;
038import org.apache.hadoop.hbase.coprocessor.CoprocessorHost;
039import org.apache.hadoop.hbase.security.HBaseKerberosUtils;
040import org.apache.hadoop.hbase.security.token.TokenProvider;
041import org.apache.hadoop.hbase.testclassification.MediumTests;
042import org.apache.hadoop.hbase.testclassification.MiscTests;
043import org.apache.hadoop.hbase.util.CommonFSUtils;
044import org.apache.hadoop.hbase.util.Pair;
045import org.apache.hadoop.minikdc.MiniKdc;
046import org.apache.hadoop.security.UserGroupInformation;
047import org.apache.http.auth.AuthSchemeProvider;
048import org.apache.http.auth.AuthScope;
049import org.apache.http.auth.KerberosCredentials;
050import org.apache.http.client.config.AuthSchemes;
051import org.apache.http.client.methods.CloseableHttpResponse;
052import org.apache.http.client.methods.HttpGet;
053import org.apache.http.config.Lookup;
054import org.apache.http.config.RegistryBuilder;
055import org.apache.http.impl.auth.SPNegoSchemeFactory;
056import org.apache.http.impl.client.BasicCredentialsProvider;
057import org.apache.http.impl.client.CloseableHttpClient;
058import org.apache.http.impl.client.HttpClients;
059import org.apache.http.util.EntityUtils;
060import org.ietf.jgss.GSSCredential;
061import org.ietf.jgss.GSSManager;
062import org.ietf.jgss.GSSName;
063import org.ietf.jgss.Oid;
064import org.junit.jupiter.api.AfterAll;
065import org.junit.jupiter.api.BeforeAll;
066import org.junit.jupiter.api.Tag;
067import org.junit.jupiter.api.Test;
068import org.slf4j.Logger;
069import org.slf4j.LoggerFactory;
070
071/**
072 * Testing info servers for admin acl.
073 */
074@Tag(MiscTests.TAG)
075@Tag(MediumTests.TAG)
076public class TestInfoServersACL {
077
078  private static final Logger LOG = LoggerFactory.getLogger(TestInfoServersACL.class);
079  private final static HBaseTestingUtil UTIL = new HBaseTestingUtil();
080  private static Configuration conf;
081
082  protected static String USERNAME;
083  private static LocalHBaseCluster CLUSTER;
084  private static final File KEYTAB_FILE = new File(UTIL.getDataTestDir("keytab").toUri().getPath());
085  private static MiniKdc KDC;
086  private static String HOST = "localhost";
087  private static String PRINCIPAL;
088  private static String HTTP_PRINCIPAL;
089
090  // user/group present in hbase.admin.acl
091  private static final String USER_ADMIN_STR = "admin";
092
093  // user with no permissions
094  private static final String USER_NONE_STR = "none";
095
096  @BeforeAll
097  public static void beforeClass() throws Exception {
098    conf = UTIL.getConfiguration();
099    KDC = UTIL.setupMiniKdc(KEYTAB_FILE);
100    USERNAME = UserGroupInformation.getLoginUser().getShortUserName();
101    PRINCIPAL = USERNAME + "/" + HOST;
102    HTTP_PRINCIPAL = "HTTP/" + HOST;
103    // Create principals for services and the test users
104    KDC.createPrincipal(KEYTAB_FILE, PRINCIPAL, HTTP_PRINCIPAL, USER_ADMIN_STR, USER_NONE_STR);
105    UTIL.startMiniZKCluster();
106
107    HBaseKerberosUtils.setSecuredConfiguration(conf, PRINCIPAL + "@" + KDC.getRealm(),
108      HTTP_PRINCIPAL + "@" + KDC.getRealm());
109    HBaseKerberosUtils.setSSLConfiguration(UTIL, TestInfoServersACL.class);
110
111    conf.setStrings(CoprocessorHost.REGION_COPROCESSOR_CONF_KEY, TokenProvider.class.getName());
112    UTIL.startMiniDFSCluster(1);
113    Path rootdir = UTIL.getDataTestDirOnTestFS("TestInfoServersACL");
114    CommonFSUtils.setRootDir(conf, rootdir);
115
116    // The info servers do not run in tests by default.
117    // Set them to ephemeral ports so they will start
118    // setup configuration
119    conf.setInt(HConstants.MASTER_INFO_PORT, 0);
120    conf.setInt(HConstants.REGIONSERVER_INFO_PORT, 0);
121
122    conf.set(HttpServer.HTTP_UI_AUTHENTICATION, "kerberos");
123    conf.set(HttpServer.HTTP_SPNEGO_AUTHENTICATION_PRINCIPAL_KEY, HTTP_PRINCIPAL);
124    conf.set(HttpServer.HTTP_SPNEGO_AUTHENTICATION_KEYTAB_KEY, KEYTAB_FILE.getAbsolutePath());
125
126    // ACL lists work only when "hadoop.security.authorization" is set to true
127    conf.setBoolean(CommonConfigurationKeys.HADOOP_SECURITY_AUTHORIZATION, true);
128    // only user admin will have acl access
129    conf.set(HttpServer.HTTP_SPNEGO_AUTHENTICATION_ADMIN_USERS_KEY, USER_ADMIN_STR);
130    // conf.set(HttpServer.FILTER_INITIALIZERS_PROPERTY, "");
131
132    CLUSTER = new LocalHBaseCluster(conf, 1);
133    CLUSTER.startup();
134    CLUSTER.getActiveMaster().waitForMetaOnline();
135  }
136
137  /**
138   * Helper method to shut down the cluster (if running)
139   */
140  @AfterAll
141  public static void shutDownMiniCluster() throws Exception {
142    if (CLUSTER != null) {
143      CLUSTER.shutdown();
144      CLUSTER.join();
145    }
146    if (KDC != null) {
147      KDC.stop();
148    }
149    UTIL.shutdownMiniCluster();
150  }
151
152  @Test
153  public void testAuthorizedUser() throws Exception {
154    UserGroupInformation admin = UserGroupInformation
155      .loginUserFromKeytabAndReturnUGI(USER_ADMIN_STR, KEYTAB_FILE.getAbsolutePath());
156    admin.doAs(new PrivilegedExceptionAction<Void>() {
157      @Override
158      public Void run() throws Exception {
159        // Check the expected content is present in the http response
160        String expectedContent = "Get Log Level";
161        Pair<Integer, String> pair = getLogLevelPage();
162        assertEquals(HttpURLConnection.HTTP_OK, pair.getFirst().intValue());
163        assertTrue(pair.getSecond().contains(expectedContent),
164          "expected=" + expectedContent + ", content=" + pair.getSecond());
165        return null;
166      }
167    });
168  }
169
170  @Test
171  public void testUnauthorizedUser() throws Exception {
172    UserGroupInformation nonAdmin = UserGroupInformation
173      .loginUserFromKeytabAndReturnUGI(USER_NONE_STR, KEYTAB_FILE.getAbsolutePath());
174    nonAdmin.doAs(new PrivilegedExceptionAction<Void>() {
175      @Override
176      public Void run() throws Exception {
177        Pair<Integer, String> pair = getLogLevelPage();
178        assertEquals(HttpURLConnection.HTTP_FORBIDDEN, pair.getFirst().intValue());
179        return null;
180      }
181    });
182  }
183
184  @Test
185  public void testTableActionsAvailableForAdmins() throws Exception {
186    final String expectedAuthorizedContent = "Actions";
187    UserGroupInformation admin = UserGroupInformation
188      .loginUserFromKeytabAndReturnUGI(USER_ADMIN_STR, KEYTAB_FILE.getAbsolutePath());
189    admin.doAs(new PrivilegedExceptionAction<Void>() {
190      @Override
191      public Void run() throws Exception {
192        // Check the expected content is present in the http response
193        Pair<Integer, String> pair = getTablePage(TableName.META_TABLE_NAME);
194        assertEquals(HttpURLConnection.HTTP_OK, pair.getFirst().intValue());
195        assertTrue(pair.getSecond().contains(expectedAuthorizedContent),
196          "expected=" + expectedAuthorizedContent + ", content=" + pair.getSecond());
197        return null;
198      }
199    });
200
201    UserGroupInformation nonAdmin = UserGroupInformation
202      .loginUserFromKeytabAndReturnUGI(USER_NONE_STR, KEYTAB_FILE.getAbsolutePath());
203    nonAdmin.doAs(new PrivilegedExceptionAction<Void>() {
204      @Override
205      public Void run() throws Exception {
206        Pair<Integer, String> pair = getTablePage(TableName.META_TABLE_NAME);
207        assertEquals(HttpURLConnection.HTTP_OK, pair.getFirst().intValue());
208        assertFalse(pair.getSecond().contains(expectedAuthorizedContent),
209          "should not find=" + expectedAuthorizedContent + ", content=" + pair.getSecond());
210        return null;
211      }
212    });
213  }
214
215  @Test
216  public void testLogsAvailableForAdmins() throws Exception {
217    final String expectedAuthorizedContent = "Directory: /logs/";
218    UserGroupInformation admin = UserGroupInformation
219      .loginUserFromKeytabAndReturnUGI(USER_ADMIN_STR, KEYTAB_FILE.getAbsolutePath());
220    admin.doAs(new PrivilegedExceptionAction<Void>() {
221      @Override
222      public Void run() throws Exception {
223        // Check the expected content is present in the http response
224        Pair<Integer, String> pair = getLogsPage();
225        assertEquals(HttpURLConnection.HTTP_OK, pair.getFirst().intValue());
226        assertTrue(pair.getSecond().contains(expectedAuthorizedContent),
227          "expected=" + expectedAuthorizedContent + ", content=" + pair.getSecond());
228        return null;
229      }
230    });
231
232    UserGroupInformation nonAdmin = UserGroupInformation
233      .loginUserFromKeytabAndReturnUGI(USER_NONE_STR, KEYTAB_FILE.getAbsolutePath());
234    nonAdmin.doAs(new PrivilegedExceptionAction<Void>() {
235      @Override
236      public Void run() throws Exception {
237        Pair<Integer, String> pair = getLogsPage();
238        assertEquals(HttpURLConnection.HTTP_FORBIDDEN, pair.getFirst().intValue());
239        return null;
240      }
241    });
242  }
243
244  @Test
245  public void testDumpActionsAvailableForAdmins() throws Exception {
246    final String expectedAuthorizedContent = "Master status for";
247    UserGroupInformation admin = UserGroupInformation
248      .loginUserFromKeytabAndReturnUGI(USER_ADMIN_STR, KEYTAB_FILE.getAbsolutePath());
249    admin.doAs(new PrivilegedExceptionAction<Void>() {
250      @Override
251      public Void run() throws Exception {
252        // Check the expected content is present in the http response
253        Pair<Integer, String> pair = getMasterDumpPage();
254        assertEquals(HttpURLConnection.HTTP_OK, pair.getFirst().intValue());
255        assertTrue(pair.getSecond().contains(expectedAuthorizedContent),
256          "expected=" + expectedAuthorizedContent + ", content=" + pair.getSecond());
257        return null;
258      }
259    });
260
261    UserGroupInformation nonAdmin = UserGroupInformation
262      .loginUserFromKeytabAndReturnUGI(USER_NONE_STR, KEYTAB_FILE.getAbsolutePath());
263    nonAdmin.doAs(new PrivilegedExceptionAction<Void>() {
264      @Override
265      public Void run() throws Exception {
266        Pair<Integer, String> pair = getMasterDumpPage();
267        assertEquals(HttpURLConnection.HTTP_FORBIDDEN, pair.getFirst().intValue());
268        return null;
269      }
270    });
271  }
272
273  @Test
274  public void testStackActionsAvailableForAdmins() throws Exception {
275    final String expectedAuthorizedContent = "Process Thread Dump";
276    UserGroupInformation admin = UserGroupInformation
277      .loginUserFromKeytabAndReturnUGI(USER_ADMIN_STR, KEYTAB_FILE.getAbsolutePath());
278    admin.doAs(new PrivilegedExceptionAction<Void>() {
279      @Override
280      public Void run() throws Exception {
281        // Check the expected content is present in the http response
282        Pair<Integer, String> pair = getStacksPage();
283        assertEquals(HttpURLConnection.HTTP_OK, pair.getFirst().intValue());
284        assertTrue(pair.getSecond().contains(expectedAuthorizedContent),
285          "expected=" + expectedAuthorizedContent + ", content=" + pair.getSecond());
286        return null;
287      }
288    });
289
290    UserGroupInformation nonAdmin = UserGroupInformation
291      .loginUserFromKeytabAndReturnUGI(USER_NONE_STR, KEYTAB_FILE.getAbsolutePath());
292    nonAdmin.doAs(new PrivilegedExceptionAction<Void>() {
293      @Override
294      public Void run() throws Exception {
295        Pair<Integer, String> pair = getStacksPage();
296        assertEquals(HttpURLConnection.HTTP_FORBIDDEN, pair.getFirst().intValue());
297        return null;
298      }
299    });
300  }
301
302  @Test
303  public void testJmxAvailableForAdmins() throws Exception {
304    final String expectedAuthorizedContent = "Hadoop:service=HBase";
305    UTIL.waitFor(30000, new Waiter.Predicate<Exception>() {
306      @Override
307      public boolean evaluate() throws Exception {
308        for (ObjectName name : ManagementFactory.getPlatformMBeanServer()
309          .queryNames(new ObjectName("*:*"), null)) {
310          if (name.toString().contains(expectedAuthorizedContent)) {
311            LOG.info("{}", name);
312            return true;
313          }
314        }
315        return false;
316      }
317    });
318    UserGroupInformation admin = UserGroupInformation
319      .loginUserFromKeytabAndReturnUGI(USER_ADMIN_STR, KEYTAB_FILE.getAbsolutePath());
320    admin.doAs(new PrivilegedExceptionAction<Void>() {
321      @Override
322      public Void run() throws Exception {
323        // Check the expected content is present in the http response
324        Pair<Integer, String> pair = getJmxPage();
325        assertEquals(HttpURLConnection.HTTP_OK, pair.getFirst().intValue());
326        assertTrue(pair.getSecond().contains(expectedAuthorizedContent),
327          "expected=" + expectedAuthorizedContent + ", content=" + pair.getSecond());
328        return null;
329      }
330    });
331
332    UserGroupInformation nonAdmin = UserGroupInformation
333      .loginUserFromKeytabAndReturnUGI(USER_NONE_STR, KEYTAB_FILE.getAbsolutePath());
334    nonAdmin.doAs(new PrivilegedExceptionAction<Void>() {
335      @Override
336      public Void run() throws Exception {
337        Pair<Integer, String> pair = getJmxPage();
338        assertEquals(HttpURLConnection.HTTP_FORBIDDEN, pair.getFirst().intValue());
339        return null;
340      }
341    });
342  }
343
344  @Test
345  public void testMetricsAvailableForAdmins() throws Exception {
346    // Looks like there's nothing exported to this, but leave it since
347    // it's Hadoop2 only and will eventually be removed due to that.
348    final String expectedAuthorizedContent = "";
349    UserGroupInformation admin = UserGroupInformation
350      .loginUserFromKeytabAndReturnUGI(USER_ADMIN_STR, KEYTAB_FILE.getAbsolutePath());
351    admin.doAs(new PrivilegedExceptionAction<Void>() {
352      @Override
353      public Void run() throws Exception {
354        // Check the expected content is present in the http response
355        Pair<Integer, String> pair = getMetricsPage();
356        if (HttpURLConnection.HTTP_NOT_FOUND == pair.getFirst()) {
357          // Not on hadoop 2
358          return null;
359        }
360        assertEquals(HttpURLConnection.HTTP_OK, pair.getFirst().intValue());
361        assertTrue(pair.getSecond().contains(expectedAuthorizedContent),
362          "expected=" + expectedAuthorizedContent + ", content=" + pair.getSecond());
363        return null;
364      }
365    });
366
367    UserGroupInformation nonAdmin = UserGroupInformation
368      .loginUserFromKeytabAndReturnUGI(USER_NONE_STR, KEYTAB_FILE.getAbsolutePath());
369    nonAdmin.doAs(new PrivilegedExceptionAction<Void>() {
370      @Override
371      public Void run() throws Exception {
372        Pair<Integer, String> pair = getMetricsPage();
373        if (HttpURLConnection.HTTP_NOT_FOUND == pair.getFirst()) {
374          // Not on hadoop 2
375          return null;
376        }
377        assertEquals(HttpURLConnection.HTTP_FORBIDDEN, pair.getFirst().intValue());
378        return null;
379      }
380    });
381  }
382
383  private String getInfoServerHostAndPort() {
384    return "http://localhost:" + CLUSTER.getActiveMaster().getInfoServer().getPort();
385  }
386
387  private Pair<Integer, String> getLogLevelPage() throws Exception {
388    // Build the url which we want to connect to
389    URL url = new URL(getInfoServerHostAndPort() + "/logLevel");
390    return getUrlContent(url);
391  }
392
393  private Pair<Integer, String> getTablePage(TableName tn) throws Exception {
394    URL url = new URL(getInfoServerHostAndPort() + "/table.jsp?name=" + tn.getNameAsString());
395    return getUrlContent(url);
396  }
397
398  private Pair<Integer, String> getLogsPage() throws Exception {
399    URL url = new URL(getInfoServerHostAndPort() + "/logs/");
400    return getUrlContent(url);
401  }
402
403  private Pair<Integer, String> getMasterDumpPage() throws Exception {
404    URL url = new URL(getInfoServerHostAndPort() + "/dump");
405    return getUrlContent(url);
406  }
407
408  private Pair<Integer, String> getStacksPage() throws Exception {
409    URL url = new URL(getInfoServerHostAndPort() + "/stacks");
410    return getUrlContent(url);
411  }
412
413  private Pair<Integer, String> getJmxPage() throws Exception {
414    URL url = new URL(getInfoServerHostAndPort() + "/jmx");
415    return getUrlContent(url);
416  }
417
418  private Pair<Integer, String> getMetricsPage() throws Exception {
419    URL url = new URL(getInfoServerHostAndPort() + "/metrics");
420    return getUrlContent(url);
421  }
422
423  /**
424   * Retrieves the content of the specified URL. The content will only be returned if the status
425   * code for the operation was HTTP 200/OK.
426   */
427  private Pair<Integer, String> getUrlContent(URL url) throws Exception {
428    try (CloseableHttpClient client =
429      createHttpClient(UserGroupInformation.getCurrentUser().getUserName())) {
430      CloseableHttpResponse resp = client.execute(new HttpGet(url.toURI()));
431      int code = resp.getStatusLine().getStatusCode();
432      if (code == HttpURLConnection.HTTP_OK) {
433        return new Pair<>(code, EntityUtils.toString(resp.getEntity()));
434      }
435      return new Pair<>(code, null);
436    }
437  }
438
439  private CloseableHttpClient createHttpClient(String clientPrincipal) throws Exception {
440    // Logs in with Kerberos via GSS
441    GSSManager gssManager = GSSManager.getInstance();
442    // jGSS Kerberos login constant
443    Oid oid = new Oid("1.2.840.113554.1.2.2");
444    GSSName gssClient = gssManager.createName(clientPrincipal, GSSName.NT_USER_NAME);
445    GSSCredential credential = gssManager.createCredential(gssClient,
446      GSSCredential.DEFAULT_LIFETIME, oid, GSSCredential.INITIATE_ONLY);
447
448    Lookup<AuthSchemeProvider> authRegistry = RegistryBuilder.<AuthSchemeProvider> create()
449      .register(AuthSchemes.SPNEGO, new SPNegoSchemeFactory(true, true)).build();
450
451    BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
452    credentialsProvider.setCredentials(AuthScope.ANY, new KerberosCredentials(credential));
453
454    return HttpClients.custom().setDefaultAuthSchemeRegistry(authRegistry)
455      .setDefaultCredentialsProvider(credentialsProvider).build();
456  }
457}