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..4734e6617d4 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,12 @@ public void init(Properties props, String propPrefix) { this.restClient.setMaxRetryAttempts(maxRetryAttempts); this.restClient.setRetryIntervalMs(retryIntervalMs); + Map spiffeHeaders = PluginHeaderAuthConfig.buildSpiffeAuthHeaders(props, propPrefix); + if (!spiffeHeaders.isEmpty()) { + this.restClient.setTrustedAuthHeaders(spiffeHeaders); + LOG.debug("SPIFFE header authentication enabled 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..42082f51e49 --- /dev/null +++ b/agents-audit/dest-auditserver/src/test/java/org/apache/ranger/audit/destination/RangerAuditServerDestinationTest.java @@ -0,0 +1,57 @@ +/* + * 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 buildSpiffeAuthHeadersUsesAuditDestinationPrefix() { + Properties props = new Properties(); + props.setProperty(AUDIT_DEST_PREFIX + ".authn.header.enabled", "true"); + props.setProperty(AUDIT_DEST_PREFIX + ".authn.header.spiffe", "X-Spiffe-Id"); + props.setProperty(AUDIT_DEST_PREFIX + ".authn.spiffe.value", + "spiffe://example.com/ns/default/sa/hive"); + + Map headers = PluginHeaderAuthConfig.buildSpiffeAuthHeaders(props, AUDIT_DEST_PREFIX); + + assertEquals(1, headers.size()); + assertEquals("spiffe://example.com/ns/default/sa/hive", headers.get("X-Spiffe-Id")); + } + + @Test + public void buildSpiffeAuthHeadersEmptyWhenAuditDestinationDisabled() { + Properties props = new Properties(); + props.setProperty(AUDIT_DEST_PREFIX + ".authn.header.enabled", "false"); + props.setProperty(AUDIT_DEST_PREFIX + ".authn.spiffe.value", + "spiffe://example.com/ns/default/sa/hive"); + + Map headers = PluginHeaderAuthConfig.buildSpiffeAuthHeaders(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/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..228d043d4fb --- /dev/null +++ b/common-utils/src/main/java/org/apache/ranger/plugin/util/PluginHeaderAuthConfig.java @@ -0,0 +1,125 @@ +/* + * 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 + * 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.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/** + * Outbound trusted-header auth for audit-server and other REST clients. + * + *

Properties are read under a caller-supplied prefix (audit destination example): + *

+ * xasecure.audit.destination.auditserver.authn.header.enabled=true
+ * xasecure.audit.destination.auditserver.authn.header.spiffe=X-Spiffe-Id
+ * 
+ * SPIFFE ID value is resolved via {@link SpiffeIdentityResolver} under the same + * prefix (explicit value, identity file, or {@code SPIFFE_ID} environment variable). + */ +public final class PluginHeaderAuthConfig { + public static final String PROP_HEADER_AUTH_ENABLED = "authn.header.enabled"; + public static final String PROP_HEADER_SPIFFE = "authn.header.spiffe"; + public static final String DEFAULT_SPIFFE_HEADER_NAME = "X-Spiffe-Id"; + + private static final Logger LOG = + LoggerFactory.getLogger(PluginHeaderAuthConfig.class); + + 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) { + if (props == null || StringUtils.isBlank(configPrefix)) { + return false; + } + + return Boolean.parseBoolean( + props.getProperty(configPrefix + "." + PROP_HEADER_AUTH_ENABLED, + "false")); + } + + /** + * Builds SPIFFE header(s) 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 buildSpiffeAuthHeaders(final Properties props, + final String configPrefix) { + if (!isHeaderAuthEnabled(props, configPrefix)) { + return Collections.emptyMap(); + } + + List headerNames = SpiffeIdUtil.parseHeaderNames( + resolveSpiffeHeaderName(props, configPrefix)); + String spiffeId = SpiffeIdentityResolver.resolve(props, configPrefix); + + if (headerNames.isEmpty()) { + LOG.warn("Plugin header auth enabled for {} but no SPIFFE header " + + "name is configured", configPrefix); + return Collections.emptyMap(); + } + + if (StringUtils.isBlank(spiffeId)) { + LOG.warn("Plugin header auth enabled for {} but no SPIFFE ID could " + + "be resolved", configPrefix); + return Collections.emptyMap(); + } + + if (!SpiffeIdUtil.isValidSpiffeId(spiffeId)) { + LOG.warn("Resolved SPIFFE ID for {} is not well-formed", configPrefix); + return Collections.emptyMap(); + } + + Map headers = new LinkedHashMap<>(); + + for (String headerName : headerNames) { + headers.put(headerName, spiffeId.trim()); + } + + return Collections.unmodifiableMap(headers); + } + + private static String resolveSpiffeHeaderName(final Properties props, + final String configPrefix) { + String headerName = props != null + ? StringUtils.trimToNull( + props.getProperty(configPrefix + "." + PROP_HEADER_SPIFFE)) + : null; + + return headerName != null ? headerName : DEFAULT_SPIFFE_HEADER_NAME; + } +} diff --git a/common-utils/src/main/java/org/apache/ranger/plugin/util/SpiffeIdentityResolver.java b/common-utils/src/main/java/org/apache/ranger/plugin/util/SpiffeIdentityResolver.java new file mode 100644 index 00000000000..1df60d408ce --- /dev/null +++ b/common-utils/src/main/java/org/apache/ranger/plugin/util/SpiffeIdentityResolver.java @@ -0,0 +1,117 @@ +/* + * 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.List; +import java.util.Properties; + +/** + * Resolves a workload SPIFFE ID from plugin/site configuration. + * + *

Resolution order: explicit {@code authn.spiffe.value}, identity file + * ({@code authn.spiffe.file} or the default SPIRE path), then {@code SPIFFE_ID} + * environment variable. + */ +public final class SpiffeIdentityResolver { + public static final String PROP_SPIFFE_VALUE = "authn.spiffe.value"; + public static final String PROP_SPIFFE_FILE = "authn.spiffe.file"; + public static final String ENV_SPIFFE_ID = "SPIFFE_ID"; + public static final String DEFAULT_SPIFFE_IDENTITY_FILE = + "/var/run/secrets/spiffe.io/identity/spiffe"; + + private static final Logger LOG = + LoggerFactory.getLogger(SpiffeIdentityResolver.class); + + private SpiffeIdentityResolver() { + // to block instantiation + } + + /** + * Resolves the SPIFFE ID for the given config prefix. + * + * @param props plugin or site configuration properties + * @param configPrefix prefix such as {@code ranger.hive} + * @return the resolved SPIFFE ID, or {@code null} when unavailable + */ + public static String resolve(final Properties props, final String configPrefix) { + if (props == null || StringUtils.isBlank(configPrefix)) { + return null; + } + + String value = StringUtils.trimToNull( + props.getProperty(configPrefix + "." + PROP_SPIFFE_VALUE)); + + if (value != null) { + return value; + } + + String filePath = StringUtils.trimToNull( + props.getProperty(configPrefix + "." + PROP_SPIFFE_FILE)); + + if (filePath == null) { + filePath = DEFAULT_SPIFFE_IDENTITY_FILE; + } + + value = readFirstLine(filePath); + + if (value != null) { + return value; + } + + return StringUtils.trimToNull(System.getenv(ENV_SPIFFE_ID)); + } + + private static String readFirstLine(final String filePath) { + if (StringUtils.isBlank(filePath)) { + return null; + } + + try { + Path path = Paths.get(filePath.trim()); + + if (!Files.isRegularFile(path)) { + return null; + } + + List lines = Files.readAllLines(path, StandardCharsets.UTF_8); + + for (String line : lines) { + String trimmed = StringUtils.trimToNull(line); + + if (trimmed != null) { + return trimmed; + } + } + } catch (IOException ex) { + LOG.debug("Unable to read SPIFFE identity from file {}", filePath, ex); + } + + return null; + } +} 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..79578cb5f9d --- /dev/null +++ b/common-utils/src/test/java/org/apache/ranger/plugin/util/PluginHeaderAuthConfigTest.java @@ -0,0 +1,73 @@ +/* + * 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 buildSpiffeAuthHeadersUsesConfiguredHeaderName() { + Properties props = new Properties(); + props.setProperty("ranger.ozone.authn.header.enabled", "true"); + props.setProperty("ranger.ozone.authn.header.spiffe", "X-Spiffe-Id"); + props.setProperty("ranger.ozone.authn.spiffe.value", VALID_SPIFFE); + + Map headers = PluginHeaderAuthConfig.buildSpiffeAuthHeaders(props, "ranger.ozone"); + + assertEquals(VALID_SPIFFE, headers.get("X-Spiffe-Id")); + } + + @Test + public void buildSpiffeAuthHeadersEmptyWhenDisabled() { + Properties props = new Properties(); + props.setProperty("ranger.ozone.authn.header.enabled", "false"); + props.setProperty("ranger.ozone.authn.spiffe.value", VALID_SPIFFE); + + assertTrue(PluginHeaderAuthConfig.buildSpiffeAuthHeaders(props, "ranger.ozone").isEmpty()); + } + + @Test + public void resolveSpiffeIdFromFile(@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.hive.authn.spiffe.file", spiffeFile.toString()); + + assertEquals(VALID_SPIFFE, SpiffeIdentityResolver.resolve(props, "ranger.hive")); + } + + @Test + public void isHeaderAuthEnabledFalseForMissingPrefix() { + assertFalse(PluginHeaderAuthConfig.isHeaderAuthEnabled(new Properties(), "ranger.ozone")); + } +}