diff --git a/agents-audit/dest-auditserver/pom.xml b/agents-audit/dest-auditserver/pom.xml index 41aa1baee1f..d512ebbae95 100644 --- a/agents-audit/dest-auditserver/pom.xml +++ b/agents-audit/dest-auditserver/pom.xml @@ -74,6 +74,12 @@ + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + org.slf4j log4j-over-slf4j diff --git a/agents-audit/dest-auditserver/src/main/java/org/apache/ranger/audit/destination/RangerAuditServerDestination.java b/agents-audit/dest-auditserver/src/main/java/org/apache/ranger/audit/destination/RangerAuditServerDestination.java index a7eacb999e3..421ac0710bb 100644 --- a/agents-audit/dest-auditserver/src/main/java/org/apache/ranger/audit/destination/RangerAuditServerDestination.java +++ b/agents-audit/dest-auditserver/src/main/java/org/apache/ranger/audit/destination/RangerAuditServerDestination.java @@ -27,6 +27,7 @@ import org.apache.ranger.audit.model.AuthzAuditEvent; import org.apache.ranger.audit.provider.MiscUtil; import org.apache.ranger.plugin.authn.DefaultJwtProvider; +import org.apache.ranger.plugin.util.PluginHeaderAuthConfig; import org.apache.ranger.plugin.util.RangerRESTClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -98,6 +99,16 @@ public void init(Properties props, String propPrefix) { this.restClient.setMaxRetryAttempts(maxRetryAttempts); this.restClient.setRetryIntervalMs(retryIntervalMs); + // Trusted header auth is orthogonal to authn.type (JWT/Basic/Kerberos): when enabled, + // trusted headers are added in addition to whatever authType configured above. + Map trustedHeaders = PluginHeaderAuthConfig.buildTrustedAuthHeaders(props, propPrefix); + + if (!trustedHeaders.isEmpty()) { + this.restClient.setTrustedAuthHeaders(trustedHeaders); + + LOG.debug("Trusted authentication headers added for audit-server destination"); + } + LOG.info("<== RangerAuditServerDestination:init()"); } diff --git a/agents-audit/dest-auditserver/src/test/java/org/apache/ranger/audit/destination/RangerAuditServerDestinationTest.java b/agents-audit/dest-auditserver/src/test/java/org/apache/ranger/audit/destination/RangerAuditServerDestinationTest.java new file mode 100644 index 00000000000..4ba059e3f38 --- /dev/null +++ b/agents-audit/dest-auditserver/src/test/java/org/apache/ranger/audit/destination/RangerAuditServerDestinationTest.java @@ -0,0 +1,56 @@ +/* + * 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.ranger.audit.destination; + +import org.apache.ranger.plugin.util.PluginHeaderAuthConfig; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class RangerAuditServerDestinationTest { + private static final String AUDIT_DEST_PREFIX = "xasecure.audit.destination.auditserver"; + + @Test + public void buildTrustedAuthHeadersUsesAuditDestinationPrefix() { + Properties props = new Properties(); + + props.setProperty(AUDIT_DEST_PREFIX + ".authn.header.enabled", "true"); + props.setProperty(AUDIT_DEST_PREFIX + ".authn.header.X-Spiffe-Id", "spiffe://prod-cluster.k8s.example.com/ns/ranger/sa/hive"); + + Map headers = PluginHeaderAuthConfig.buildTrustedAuthHeaders(props, AUDIT_DEST_PREFIX); + + assertEquals(1, headers.size()); + assertEquals("spiffe://prod-cluster.k8s.example.com/ns/ranger/sa/hive", headers.get("X-Spiffe-Id")); + } + + @Test + public void buildTrustedAuthHeadersEmptyWhenAuditDestinationDisabled() { + Properties props = new Properties(); + + props.setProperty(AUDIT_DEST_PREFIX + ".authn.header.enabled", "false"); + props.setProperty(AUDIT_DEST_PREFIX + ".authn.header.X-Spiffe-Id", "spiffe://prod-cluster.k8s.example.com/ns/ranger/sa/hive"); + + Map headers = PluginHeaderAuthConfig.buildTrustedAuthHeaders(props, AUDIT_DEST_PREFIX); + + assertTrue(headers.isEmpty()); + } +} diff --git a/agents-common/src/main/java/org/apache/ranger/plugin/util/RangerRESTClient.java b/agents-common/src/main/java/org/apache/ranger/plugin/util/RangerRESTClient.java index d4d49523bd2..94046a549ef 100644 --- a/agents-common/src/main/java/org/apache/ranger/plugin/util/RangerRESTClient.java +++ b/agents-common/src/main/java/org/apache/ranger/plugin/util/RangerRESTClient.java @@ -60,6 +60,8 @@ import java.security.SecureRandom; import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Random; @@ -143,6 +145,7 @@ public String getMethod() { private volatile Client cookieAuthClient; private JwtProvider jwtProvider; private volatile String authHeader; + private volatile Map trustedAuthHeaders = Collections.emptyMap(); public RangerRESTClient(String url, String sslConfigFileName, Configuration config) { this(url, sslConfigFileName, config, getPropertyPrefix(config)); @@ -215,6 +218,19 @@ public void setRetryIntervalMs(int retryIntervalMs) { this.retryIntervalMs = retryIntervalMs; } + /** + * Trusted HTTP headers for SPIFFE or other header-based auth. + * Applied to every REST request from this client. + */ + public void setTrustedAuthHeaders(Map headers) { + if (headers == null || headers.isEmpty()) { + trustedAuthHeaders = Collections.emptyMap(); + } else { + trustedAuthHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(headers)); + } + resetClient(); + } + public void setBasicAuthInfo(String username, String password) { setBasicAuthFilter(username, password); } @@ -494,9 +510,17 @@ private Invocation.Builder createInvocationBuilder(int currentIndex, String rela builder = builder.cookie(sessionId); } + applyTrustedAuthHeaders(builder); + return builder; } + private void applyTrustedAuthHeaders(Invocation.Builder builder) { + for (Map.Entry entry : trustedAuthHeaders.entrySet()) { + builder.header(entry.getKey(), entry.getValue()); + } + } + private Response performRequest(HttpMethod method, String relativeUrl, Map params, Object requestBody, Cookie sessionId) throws Exception { Response finalResponse = null; int startIndex = this.lastKnownActiveUrlIndex; diff --git a/agents-common/src/test/java/org/apache/ranger/plugin/util/TestRangerRESTClient.java b/agents-common/src/test/java/org/apache/ranger/plugin/util/TestRangerRESTClient.java index 9d837a2c4ce..2f5f6ca221e 100644 --- a/agents-common/src/test/java/org/apache/ranger/plugin/util/TestRangerRESTClient.java +++ b/agents-common/src/test/java/org/apache/ranger/plugin/util/TestRangerRESTClient.java @@ -19,11 +19,23 @@ package org.apache.ranger.plugin.util; +import com.sun.net.httpserver.HttpServer; +import org.apache.hadoop.conf.Configuration; import org.apache.ranger.authorization.hadoop.config.RangerPluginConfig; import org.apache.ranger.plugin.policyengine.RangerPolicyEngineOptions; import org.apache.ranger.plugin.service.RangerBasePlugin; import org.junit.jupiter.api.Test; +import javax.ws.rs.core.Response; + +import java.net.InetSocketAddress; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -33,6 +45,8 @@ public class TestRangerRESTClient { private static final String SERVICE_NAME = "test-service"; private static final String APP_ID = "test-app"; private static final String ERR_MESSAGE = "Ranger URL is null or empty."; + private static final String VALID_SPIFFE = + "spiffe://prod-cluster.k8s.example.com/ns/ranger/sa/om"; @Test public void testPluginInit_WithNoUrl_ThrowsException() { @@ -50,4 +64,41 @@ public void testPluginInit_WithValidUrl_Succeeds() { plugin.init(); assertNotNull(plugin, "RangerBasePlugin should be initialized successfully"); } + + @Test + public void setTrustedAuthHeadersAddsHeaderToOutboundRequest() throws Exception { + AtomicReference capturedSpiffeHeader = new AtomicReference<>(); + HttpServer httpServer = HttpServer.create(new InetSocketAddress(0), 0); + + httpServer.createContext("/", exchange -> { + List values = exchange.getRequestHeaders().get("X-Spiffe-Id"); + + if (values != null && !values.isEmpty()) { + capturedSpiffeHeader.set(values.get(0)); + } + + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + + httpServer.start(); + + try { + String serverUrl = "http://localhost:" + httpServer.getAddress().getPort(); + Configuration conf = new Configuration(); + RangerRESTClient client = new RangerRESTClient(serverUrl, null, conf); + Map headers = new LinkedHashMap<>(); + + headers.put("X-Spiffe-Id", VALID_SPIFFE); + client.setTrustedAuthHeaders(headers); + + try (Response response = client.get("/test", Collections.emptyMap())) { + assertEquals(200, response.getStatus()); + } + + assertEquals(VALID_SPIFFE, capturedSpiffeHeader.get()); + } finally { + httpServer.stop(0); + } + } } diff --git a/common-utils/src/main/java/org/apache/ranger/plugin/util/PluginHeaderAuthConfig.java b/common-utils/src/main/java/org/apache/ranger/plugin/util/PluginHeaderAuthConfig.java new file mode 100644 index 00000000000..a96a81fa973 --- /dev/null +++ b/common-utils/src/main/java/org/apache/ranger/plugin/util/PluginHeaderAuthConfig.java @@ -0,0 +1,166 @@ +/* + * 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.ranger.plugin.util; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +/** + * Outbound trusted-header auth for audit-server and other REST clients. + * + *

When {@code authn.header.enabled=true}, trusted HTTP headers are added to every + * outbound REST request. Each header is configured as a property whose name is the + * HTTP header name under {@code authn.header} (audit destination example): + *

+ * xasecure.audit.destination.auditserver.authn.header.enabled=true
+ * xasecure.audit.destination.auditserver.authn.header.X-Spiffe-Id=file:/path/to/spiffe-id.file
+ * # valid value specs:
+ * #   file:/path/to/spiffe-id.file
+ * #   env:SPIFFE_ID
+ * #   spiffe://trust-domain/ns/.../sa/...  (literal string)
+ * 
+ */ +public final class PluginHeaderAuthConfig { + private static final Logger LOG = LoggerFactory.getLogger(PluginHeaderAuthConfig.class); + + public static final String PROP_HEADER_PREFIX = "authn.header."; + public static final String PROP_HEADER_AUTH_ENABLED = PROP_HEADER_PREFIX + "enabled"; + + private static final String VALUE_PREFIX_FILE = "file:"; + private static final String VALUE_PREFIX_ENV = "env:"; + + private PluginHeaderAuthConfig() { + // to block instantiation + } + + /** + * Returns whether trusted header auth is enabled for the given config prefix. + * + * @param props plugin or site configuration properties + * @param configPrefix property prefix for header-auth settings + * @return {@code true} when header auth is enabled + */ + public static boolean isHeaderAuthEnabled(final Properties props, final String configPrefix) { + return props != null && StringUtils.isNotBlank(configPrefix) && Boolean.parseBoolean(props.getProperty(configPrefix + "." + PROP_HEADER_AUTH_ENABLED, "false")); + } + + /** + * Builds trusted HTTP headers for outbound REST calls when header auth is enabled. + * + * @param props plugin or site configuration properties + * @param configPrefix prefix such as {@code xasecure.audit.destination.auditserver} + * @return immutable header map; empty when auth is disabled or misconfigured + */ + public static Map buildTrustedAuthHeaders(final Properties props, final String configPrefix) { + final Map ret; + + if (isHeaderAuthEnabled(props, configPrefix)) { + String propertyPrefix = configPrefix + "." + PROP_HEADER_PREFIX; + Map headers = new LinkedHashMap<>(); + + for (String propertyName : props.stringPropertyNames()) { + if (!propertyName.startsWith(propertyPrefix)) { + continue; + } + + String headerName = propertyName.substring(propertyPrefix.length()); + + if (StringUtils.isBlank(headerName) || "enabled".equals(headerName)) { + continue; + } + + String headerValue = resolveConfiguredValue(props.getProperty(propertyName)); + + addConfiguredHeader(headers, configPrefix, headerName, headerValue); + } + + if (headers.isEmpty()) { + LOG.warn("Plugin header auth enabled for {} but no trusted headers could be resolved", configPrefix); + } + + ret = Collections.unmodifiableMap(headers); + } else { + ret = Collections.emptyMap(); + } + + return ret; + } + + private static void addConfiguredHeader(final Map headers, final String configPrefix, final String headerName, final String headerValue) { + if (StringUtils.isBlank(headerValue)) { + LOG.warn("Plugin header auth enabled for {} but trusted header {} has no resolvable value", configPrefix, headerName); + } else { + headers.put(headerName, headerValue); + } + } + + private static String resolveConfiguredValue(final String valueSpec) { + final String ret; + + if (StringUtils.isBlank(valueSpec)) { + ret = null; + } else if (valueSpec.startsWith(VALUE_PREFIX_FILE)) { + ret = readFirstNonBlankLine(valueSpec.substring(VALUE_PREFIX_FILE.length())); + } else if (valueSpec.startsWith(VALUE_PREFIX_ENV)) { + ret = StringUtils.trimToNull(System.getenv(valueSpec.substring(VALUE_PREFIX_ENV.length()))); + } else { + ret = StringUtils.trimToNull(valueSpec); + } + + return ret; + } + + private static String readFirstNonBlankLine(final String filePath) { + String ret = null; + + if (StringUtils.isNotBlank(filePath)) { + try { + Path path = Paths.get(filePath.trim()); + + if (Files.isRegularFile(path)) { + for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { + String trimmed = StringUtils.trimToNull(line); + + if (trimmed != null) { + ret = trimmed; + + break; + } + } + } + } catch (IOException ex) { + LOG.debug("Unable to read trusted header value from file {}", filePath, ex); + } + } + + return ret; + } +} diff --git a/common-utils/src/test/java/org/apache/ranger/plugin/util/PluginHeaderAuthConfigTest.java b/common-utils/src/test/java/org/apache/ranger/plugin/util/PluginHeaderAuthConfigTest.java new file mode 100644 index 00000000000..98286c39a11 --- /dev/null +++ b/common-utils/src/test/java/org/apache/ranger/plugin/util/PluginHeaderAuthConfigTest.java @@ -0,0 +1,92 @@ +/* + * 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.ranger.plugin.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class PluginHeaderAuthConfigTest { + private static final String VALID_SPIFFE = + "spiffe://prod-cluster.k8s.example.com/ns/ranger/sa/om"; + + @Test + public void buildTrustedAuthHeadersUsesLiteralHeaderValue() { + Properties props = new Properties(); + props.setProperty("ranger.ozone.authn.header.enabled", "true"); + props.setProperty("ranger.ozone.authn.header.X-Spiffe-Id", VALID_SPIFFE); + + Map headers = PluginHeaderAuthConfig.buildTrustedAuthHeaders(props, "ranger.ozone"); + + assertEquals(VALID_SPIFFE, headers.get("X-Spiffe-Id")); + } + + @Test + public void buildTrustedAuthHeadersUsesFileValueSpec(@TempDir Path tempDir) throws Exception { + Path spiffeFile = tempDir.resolve("spiffe"); + Files.writeString(spiffeFile, VALID_SPIFFE + "\n", StandardCharsets.UTF_8); + + Properties props = new Properties(); + props.setProperty("ranger.ozone.authn.header.enabled", "true"); + props.setProperty("ranger.ozone.authn.header.X-Spiffe-Id", "file:" + spiffeFile); + + Map headers = PluginHeaderAuthConfig.buildTrustedAuthHeaders(props, "ranger.ozone"); + + assertEquals(VALID_SPIFFE, headers.get("X-Spiffe-Id")); + } + + @Test + public void buildTrustedAuthHeadersEmptyWhenDisabled() { + Properties props = new Properties(); + props.setProperty("ranger.ozone.authn.header.enabled", "false"); + props.setProperty("ranger.ozone.authn.header.X-Spiffe-Id", VALID_SPIFFE); + + assertTrue(PluginHeaderAuthConfig.buildTrustedAuthHeaders(props, "ranger.ozone").isEmpty()); + } + + @Test + public void isHeaderAuthEnabledFalseForMissingPrefix() { + assertFalse(PluginHeaderAuthConfig.isHeaderAuthEnabled(new Properties(), "ranger.ozone")); + } + + @Test + public void buildTrustedAuthHeadersEmptyWhenNoHeadersConfigured() { + Properties props = new Properties(); + props.setProperty("ranger.ozone.authn.header.enabled", "true"); + + assertTrue(PluginHeaderAuthConfig.buildTrustedAuthHeaders(props, "ranger.ozone").isEmpty()); + } + + @Test + public void buildTrustedAuthHeadersEmptyWhenSpiffeIdUnresolved() { + Properties props = new Properties(); + props.setProperty("ranger.ozone.authn.header.enabled", "true"); + props.setProperty("ranger.ozone.authn.header.X-Spiffe-Id", "env:UNSET_SPIFFE_ID_VAR"); + + assertTrue(PluginHeaderAuthConfig.buildTrustedAuthHeaders(props, "ranger.ozone").isEmpty()); + } +}