diff --git a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/config/constants/WebServerConfig.java b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/config/constants/WebServerConfig.java index 3499b04a0e..8cdecaf4c7 100644 --- a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/config/constants/WebServerConfig.java +++ b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/config/constants/WebServerConfig.java @@ -314,6 +314,18 @@ public final class WebServerConfig { + "to authenticate clients. This principal is stored in spnego.keytab.file. This must be a fully qualified principal " + "in the service/host@REALM format (service is usually HTTP)."; + /** + * spnego.principal.to.local.rules + */ + public static final String SPNEGO_PRINCIPAL_TO_LOCAL_RULES_CONFIG = + "spnego.principal.to.local.rules"; + public static final String DEFAULT_SPNEGO_PRINCIPAL_TO_LOCAL_RULES = null; + public static final String SPNEGO_PRINCIPAL_TO_LOCAL_RULES_DOC = "A list of rules for mapping from principal " + + "names to short names (typically operating system usernames). The rules are evaluated in order and the " + + "first rule that matches a principal name is used to map it to a short name. Any later rules in the list are " + + "ignored. By default, principal names of the form {username}/{hostname}@{REALM} are mapped " + + "to {username}. When not specified, the short name will be used."; + /** * trusted.proxy.services */ @@ -560,6 +572,11 @@ public static ConfigDef define(ConfigDef configDef) { DEFAULT_SPNEGO_PRINCIPAL, ConfigDef.Importance.MEDIUM, SPNEGO_PRINCIPAL_DOC) + .define(SPNEGO_PRINCIPAL_TO_LOCAL_RULES_CONFIG, + ConfigDef.Type.LIST, + DEFAULT_SPNEGO_PRINCIPAL_TO_LOCAL_RULES, + ConfigDef.Importance.MEDIUM, + SPNEGO_PRINCIPAL_TO_LOCAL_RULES_DOC) .define(TRUSTED_PROXY_SERVICES_CONFIG, ConfigDef.Type.LIST, DEFAULT_TRUSTED_PROXY_SERVICES, diff --git a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/DummyAuthorizationService.java b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/DummyAuthorizationService.java new file mode 100644 index 0000000000..1b4b7e405e --- /dev/null +++ b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/DummyAuthorizationService.java @@ -0,0 +1,40 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ + +package com.linkedin.kafka.cruisecontrol.servlet.security; + +import org.eclipse.jetty.security.SpnegoUserIdentity; +import org.eclipse.jetty.security.SpnegoUserPrincipal; +import org.eclipse.jetty.security.authentication.AuthorizationService; +import org.eclipse.jetty.server.UserIdentity; +import org.eclipse.jetty.util.security.Credential; +import javax.security.auth.Subject; +import javax.servlet.http.HttpServletRequest; +import java.security.Principal; + +public class DummyAuthorizationService implements AuthorizationService { + + private static final Credential NO_CREDENTIAL = new Credential() { + @Override + public boolean check(Object credentials) { + return false; + } + }; + + @Override + public UserIdentity getUserIdentity(HttpServletRequest request, String name) { + return createUserIdentity(name); + } + + private UserIdentity createUserIdentity(String username) { + Principal userPrincipal = new SpnegoUserPrincipal(username, ""); + Subject subject = new Subject(); + subject.getPrincipals().add(userPrincipal); + subject.getPrivateCredentials().add(NO_CREDENTIAL); + subject.setReadOnly(); + + return new SpnegoUserIdentity(subject, userPrincipal, null); + } + +} diff --git a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/PrincipalName.java b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/PrincipalName.java new file mode 100644 index 0000000000..051bbef2a3 --- /dev/null +++ b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/PrincipalName.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ + +package com.linkedin.kafka.cruisecontrol.servlet.security.spnego; + +import java.util.Objects; + +public class PrincipalName { + private final String _primary; + private final String _instance; + private final String _realm; + + public PrincipalName(String primary, String instance, String realm) { + _primary = Objects.requireNonNull(primary, "primary must not be null"); + _instance = instance; + _realm = realm; + } + + public PrincipalName(String primary) { + _primary = Objects.requireNonNull(primary, "primary must not be null"); + _instance = null; + _realm = null; + } + + public String getPrimary() { + return _primary; + } + + public String getInstance() { + return _instance; + } + + public String getRealm() { + return _realm; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || !Objects.equals(getClass(), o.getClass())) { + return false; + } + PrincipalName principalName = (PrincipalName) o; + return _primary.equals(principalName._primary) && Objects.equals(_instance, principalName._instance) + && Objects.equals(_realm, principalName._realm); + } + + @Override + public int hashCode() { + return Objects.hash(_primary, _instance, _realm); + } + + @Override + public String toString() { + return "PrincipalName{" + + "primary='" + _primary + '\'' + + ", instance='" + _instance + '\'' + + ", realm='" + _realm + '\'' + + '}'; + } +} diff --git a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/PrincipalValidator.java b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/PrincipalValidator.java new file mode 100644 index 0000000000..e35e9655f5 --- /dev/null +++ b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/PrincipalValidator.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ + +package com.linkedin.kafka.cruisecontrol.servlet.security.spnego; + +import org.apache.kafka.common.config.ConfigDef.Validator; +import org.apache.kafka.common.config.ConfigException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class PrincipalValidator implements Validator { + private static final Pattern PRINCIPAL_REGEX = + Pattern.compile("(?[^/\\s@]+)(/(?[\\w.-]+))?(@(?(\\S+)))?"); + + private final boolean _instanceRequired; + private final boolean _realmRequired; + + public PrincipalValidator(boolean instanceRequired, boolean realmRequired) { + _instanceRequired = instanceRequired; + _realmRequired = realmRequired; + } + + /** + * Creates a PrincipalName object. + * @param configName The name of the configuration + * @param principal The principal which will be the base of the PrincipalName object + * @return PrincipalName object + */ + public static PrincipalName parsePrincipal(String configName, String principal) { + Matcher matcher = PRINCIPAL_REGEX.matcher(principal); + if (!matcher.matches()) { + throw new ConfigException(configName, principal, "Invalid principal"); + } + String primary = matcher.group("primary"); + String instance = matcher.group("instance"); + String realm = matcher.group("realm"); + return new PrincipalName(primary, instance, realm); + } + + @Override + public void ensureValid(String name, Object value) { + if (value == null) { + return; + } + + if (!(value instanceof String)) { + throw new ConfigException(name, value, "Value must be string"); + } + + String strVal = (String) value; + PrincipalName principalName = parsePrincipal(name, strVal); + if (_instanceRequired && principalName.getInstance() == null) { + throw new ConfigException(name, strVal, "Principal must contain the instance section"); + } + if (_realmRequired && principalName.getRealm() == null) { + throw new ConfigException(name, strVal, "Principal must contain the realm section"); + } + } +} diff --git a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoLoginServiceWithAuthServiceLifecycle.java b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoLoginServiceWithAuthServiceLifecycle.java index 1e357d2841..d5077eaf9c 100644 --- a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoLoginServiceWithAuthServiceLifecycle.java +++ b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoLoginServiceWithAuthServiceLifecycle.java @@ -4,41 +4,237 @@ package com.linkedin.kafka.cruisecontrol.servlet.security.spnego; +import com.linkedin.kafka.cruisecontrol.servlet.security.DummyAuthorizationService; import com.linkedin.kafka.cruisecontrol.servlet.security.UserStoreAuthorizationService; +import org.apache.kafka.common.security.kerberos.KerberosName; +import org.apache.kafka.common.security.kerberos.KerberosShortNamer; import org.eclipse.jetty.security.ConfigurableSpnegoLoginService; +import org.eclipse.jetty.security.IdentityService; +import org.eclipse.jetty.security.LoginService; import org.eclipse.jetty.security.PropertyUserStore; +import org.eclipse.jetty.security.SpnegoUserIdentity; +import org.eclipse.jetty.security.SpnegoUserPrincipal; import org.eclipse.jetty.security.authentication.AuthorizationService; -import org.eclipse.jetty.util.component.LifeCycle; +import org.eclipse.jetty.server.UserIdentity; +import org.eclipse.jetty.util.component.ContainerLifeCycle; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import javax.security.auth.Subject; +import javax.servlet.ServletRequest; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Path; +import java.security.PrivilegedAction; +import java.util.List; /** - * This class is purely needed in order to manage the {@link AuthorizationService} if it is a {@link LifeCycle} bean. + * This class is purely needed in order to manage the {@link AuthorizationService}. * For instance if the AuthorizationService holds a {@link org.eclipse.jetty.security.PropertyUserStore} then it would load * users from the store during the {@link PropertyUserStore#start()} method. * * @see UserStoreAuthorizationService */ -public class SpnegoLoginServiceWithAuthServiceLifecycle extends ConfigurableSpnegoLoginService { +public class SpnegoLoginServiceWithAuthServiceLifecycle extends ContainerLifeCycle implements LoginService { + private static final Logger LOG = LoggerFactory.getLogger(SpnegoLoginServiceWithAuthServiceLifecycle.class); + private static final String GSS_HOLDER_CLASS_NAME = "org.eclipse.jetty.security.ConfigurableSpnegoLoginService$GSSContextHolder"; + private static final String REQUEST_ATTR_ADDED_NEW_SESSION = SpnegoLoginServiceWithAuthServiceLifecycle.class.getName() + "#ADDED_NEW_SESSION"; + private final GSSManager _gssManager = GSSManager.getInstance(); + private final ConfigurableSpnegoLoginService _spnegoLoginService; private final AuthorizationService _authorizationService; + private final KerberosShortNamer _kerberosShortNamer; + private Subject _spnegoSubject; + private GSSCredential _spnegoServiceCredential; + private Constructor _holderConstructor; - public SpnegoLoginServiceWithAuthServiceLifecycle(String realm, AuthorizationService authorizationService) { - super(realm, authorizationService); + public SpnegoLoginServiceWithAuthServiceLifecycle(String realm, AuthorizationService authorizationService, List principalToLocalRules) { + _spnegoLoginService = new ConfigurableSpnegoLoginService(realm, new DummyAuthorizationService()); _authorizationService = authorizationService; + _kerberosShortNamer = principalToLocalRules == null || principalToLocalRules.isEmpty() + ? null + : KerberosShortNamer.fromUnparsedRules(realm, principalToLocalRules); } @Override protected void doStart() throws Exception { - if (_authorizationService instanceof LifeCycle) { - ((LifeCycle) _authorizationService).start(); - } + addBean(_spnegoLoginService); + addBean(_authorizationService); super.doStart(); + extractSpnegoContext(); + } + + @Override + public String getName() { + return _spnegoLoginService.getName(); + } + + @Override + public UserIdentity login(String username, Object credentials, ServletRequest req) { + // save GSS context + HttpServletRequest request = (HttpServletRequest) req; + GSSContext gssContext = addContext(request); + + // authentication + SpnegoUserIdentity userIdentity = (SpnegoUserIdentity) _spnegoLoginService.login(username, credentials, req); + SpnegoUserPrincipal userPrincipal = (SpnegoUserPrincipal) userIdentity.getUserPrincipal(); + + // get full principal and create user principal shortname + String fullPrincipal = getFullPrincipalFromGssContext(gssContext); + cleanRequest(request); + LOG.debug("User {} logged in with full principal {}", userPrincipal.getName(), fullPrincipal); + String userShortname = getSpnegoUserPrincipalShortname(fullPrincipal); + + // do authorization and create UserIdentity + userPrincipal = new SpnegoUserPrincipal(userShortname, userPrincipal.getEncodedToken()); + UserIdentity roleDelegate = _authorizationService.getUserIdentity((HttpServletRequest) req, userShortname); + return new SpnegoUserIdentity(userIdentity.getSubject(), userPrincipal, roleDelegate); + } + + @Override + public boolean validate(UserIdentity user) { + return _spnegoLoginService.validate(user); } @Override - protected void doStop() throws Exception { - super.doStop(); - if (_authorizationService instanceof LifeCycle) { - ((LifeCycle) _authorizationService).stop(); + public IdentityService getIdentityService() { + return _spnegoLoginService.getIdentityService(); + } + + @Override + public void setIdentityService(IdentityService service) { + _spnegoLoginService.setIdentityService(service); + } + + @Override + public void logout(UserIdentity user) { + _spnegoLoginService.logout(user); + } + + public void setServiceName(String serviceName) { + _spnegoLoginService.setServiceName(serviceName); + } + + public void setHostName(String hostName) { + _spnegoLoginService.setHostName(hostName); + } + + public void setKeyTabPath(Path keyTabFile) { + _spnegoLoginService.setKeyTabPath(keyTabFile); + } + + private String getFullPrincipal(HttpServletRequest request) { + String fullPrincipal; + try { + fullPrincipal = ((GSSContext) request.getSession() + .getAttribute(GSS_HOLDER_CLASS_NAME)) + .getSrcName() + .toString(); + } catch (GSSException e) { + throw new RuntimeException(e); } + return fullPrincipal; } + + private String getSpnegoUserPrincipalShortname(String fullPrincipal) { + PrincipalName userPrincipalName = PrincipalValidator.parsePrincipal("", fullPrincipal); + + if (_kerberosShortNamer == null) { + return userPrincipalName.getPrimary(); + } + + try { + String userShortname = _kerberosShortNamer.shortName(new KerberosName(userPrincipalName.getPrimary(), + userPrincipalName.getInstance(), userPrincipalName.getRealm())); + LOG.debug("Principal {} was shortened to {}", userPrincipalName, userShortname); + return userShortname; + } catch (IOException e) { + throw new RuntimeException("Could not generate short name for principal " + userPrincipalName, e); + } + } + + private String getFullPrincipalFromGssContext(GSSContext gssContext) { + try { + return gssContext.getSrcName().toString(); + } catch (GSSException e) { + throw new RuntimeException("Failed to extract full principal", e); + } + } + + // Visible for testing + void extractSpnegoContext() { + // All the following code depends on the structure of org.eclipse.jetty.security.ConfigurableSpnegoLoginService$GSSContextHolder and + // org.eclipse.jetty.security.ConfigurableSpnegoLoginService$SpnegoContext + // If jetty is upgraded, this code might brake + try { + Field contextField = ConfigurableSpnegoLoginService.class.getDeclaredField("_context"); + contextField.setAccessible(true); + Object spnegoContext = contextField.get(_spnegoLoginService); + Class contextClass = spnegoContext.getClass(); + Field contextSubjectField = contextClass.getDeclaredField("_subject"); + contextSubjectField.setAccessible(true); + _spnegoSubject = (Subject) contextSubjectField.get(spnegoContext); + Field contextCredentialField = contextClass.getDeclaredField("_serviceCredential"); + contextCredentialField.setAccessible(true); + _spnegoServiceCredential = (GSSCredential) contextCredentialField.get(spnegoContext); + Class gssHolder = Class.forName(GSS_HOLDER_CLASS_NAME); + _holderConstructor = gssHolder.getDeclaredConstructor(GSSContext.class); + _holderConstructor.setAccessible(true); + } catch (NoSuchFieldException | IllegalAccessException | ClassNotFoundException | NoSuchMethodException e) { + throw new RuntimeException("Failed to init SPNEGO context", e); + } + + } + + private GSSContext addContext(HttpServletRequest request) { + // This tries to inject an externally created GSSContext into ConfigurableSpnegoLoginService. + // ConfigurableSpnegoLoginService drops the realm part of the client principal, but we need that to evaluate the auth to local rules + // By injecting the GSSContext through the session, we can get access to the full principal + // If jetty is upgraded, this code might brake + try { + GSSContext gssContext = Subject.doAs(_spnegoSubject, newGSSContext(_spnegoServiceCredential)); + Object holder = _holderConstructor.newInstance(gssContext); + boolean needsNewSession = request.getSession(false) == null; + if (needsNewSession) { + request.setAttribute(REQUEST_ATTR_ADDED_NEW_SESSION, "true"); + } + request.getSession(true).setAttribute(GSS_HOLDER_CLASS_NAME, holder); + return gssContext; + } catch (IllegalAccessException | InstantiationException | InvocationTargetException e) { + throw new RuntimeException("Failed to perform SPNEGO authentication", e); + } + } + + private void cleanRequest(HttpServletRequest request) { + if (!"true".equals(request.getAttribute(REQUEST_ATTR_ADDED_NEW_SESSION))) { + return; + } + request.removeAttribute(REQUEST_ATTR_ADDED_NEW_SESSION); + HttpSession session = request.getSession(); + if (session != null) { + try { + session.invalidate(); + } catch (Exception ignored) { + // NOP + } + } + } + + private PrivilegedAction newGSSContext(GSSCredential serviceCredential) { + return () -> { + try { + return _gssManager.createContext(serviceCredential); + } catch (GSSException e) { + throw new RuntimeException(e); + } + }; + } + } diff --git a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProvider.java b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProvider.java index 8a46b6c575..972a97f4e4 100644 --- a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProvider.java +++ b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProvider.java @@ -9,11 +9,11 @@ import com.linkedin.kafka.cruisecontrol.servlet.security.DefaultRoleSecurityProvider; import org.apache.kafka.common.security.kerberos.KerberosName; import org.eclipse.jetty.security.Authenticator; -import org.eclipse.jetty.security.ConfigurableSpnegoLoginService; import org.eclipse.jetty.security.LoginService; import org.eclipse.jetty.security.authentication.AuthorizationService; import org.eclipse.jetty.security.authentication.ConfigurableSpnegoAuthenticator; import java.nio.file.Paths; +import java.util.List; /** * Defines an SPNEGO capable login service using the HTTP Negotiate authentication mechanism. @@ -23,6 +23,7 @@ public class SpnegoSecurityProvider extends DefaultRoleSecurityProvider { protected String _privilegesFilePath; protected String _keyTabPath; protected KerberosName _spnegoPrincipal; + private List _spnegoPrincipalToLocalRules; @Override public void init(KafkaCruiseControlConfig config) { @@ -30,11 +31,13 @@ public void init(KafkaCruiseControlConfig config) { _privilegesFilePath = config.getString(WebServerConfig.WEBSERVER_AUTH_CREDENTIALS_FILE_CONFIG); _keyTabPath = config.getString(WebServerConfig.SPNEGO_KEYTAB_FILE_CONFIG); _spnegoPrincipal = KerberosName.parse(config.getString(WebServerConfig.SPNEGO_PRINCIPAL_CONFIG)); + _spnegoPrincipalToLocalRules = config.getList(WebServerConfig.SPNEGO_PRINCIPAL_TO_LOCAL_RULES_CONFIG); } @Override public LoginService loginService() { - ConfigurableSpnegoLoginService loginService = new SpnegoLoginServiceWithAuthServiceLifecycle(_spnegoPrincipal.realm(), authorizationService()); + SpnegoLoginServiceWithAuthServiceLifecycle loginService = new SpnegoLoginServiceWithAuthServiceLifecycle( + _spnegoPrincipal.realm(), authorizationService(), _spnegoPrincipalToLocalRules); loginService.setServiceName(_spnegoPrincipal.serviceName()); loginService.setHostName(_spnegoPrincipal.hostName()); loginService.setKeyTabPath(Paths.get(_keyTabPath)); diff --git a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxyLoginService.java b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxyLoginService.java index 881484a8a9..ebf39b18d9 100644 --- a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxyLoginService.java +++ b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxyLoginService.java @@ -26,7 +26,7 @@ import static com.linkedin.kafka.cruisecontrol.servlet.parameters.ParameterUtils.DO_AS; /** - * TrustedProxyLoginService is a special SPNEGO login service where we only allow a list of trusted services + * {@code TrustedProxyLoginService} is a special SPNEGO login service where we only allow a list of trusted services * to act on behalf of clients. The login service authenticates the trusted party but creates credentials for the client * based on the {@link AuthorizationService}. */ @@ -52,10 +52,10 @@ public class TrustedProxyLoginService extends ContainerLifeCycle implements Logi * Cruise Control as trusted proxies */ public TrustedProxyLoginService(String realm, AuthorizationService userAuthorizer, List trustedProxies, - String trustedProxyIpPattern, boolean fallbackToSpnegoAllowed) { + String trustedProxyIpPattern, boolean fallbackToSpnegoAllowed, List principalToLocalRules) { _delegateSpnegoLoginService = new SpnegoLoginServiceWithAuthServiceLifecycle( - realm, new TrustedProxyAuthorizationService(trustedProxies, trustedProxyIpPattern)); - _fallbackSpnegoLoginService = new SpnegoLoginServiceWithAuthServiceLifecycle(realm, userAuthorizer); + realm, new TrustedProxyAuthorizationService(trustedProxies, trustedProxyIpPattern), principalToLocalRules); + _fallbackSpnegoLoginService = new SpnegoLoginServiceWithAuthServiceLifecycle(realm, userAuthorizer, principalToLocalRules); _endUserAuthorizer = userAuthorizer; _fallbackToSpnegoAllowed = fallbackToSpnegoAllowed; } @@ -114,8 +114,8 @@ public UserIdentity login(String username, Object credentials, ServletRequest re String doAsUser = request.getParameter(DO_AS); if (doAsUser == null && _fallbackToSpnegoAllowed) { SpnegoUserIdentity fallbackIdentity = (SpnegoUserIdentity) _fallbackSpnegoLoginService.login(username, credentials, request); - SpnegoUserPrincipal fallbackPrincipal = (SpnegoUserPrincipal) fallbackIdentity.getUserPrincipal(); if (!fallbackIdentity.isEstablished()) { + SpnegoUserPrincipal fallbackPrincipal = (SpnegoUserPrincipal) fallbackIdentity.getUserPrincipal(); LOG.info("Service user {} isn't authorized as spnego fallback principal", fallbackPrincipal.getName()); } return fallbackIdentity; @@ -178,7 +178,7 @@ protected void doStart() throws Exception { @Override protected void doStop() throws Exception { super.doStop(); - _fallbackSpnegoLoginService.start(); + _fallbackSpnegoLoginService.stop(); _delegateSpnegoLoginService.stop(); if (_endUserAuthorizer instanceof LifeCycle) { ((LifeCycle) _endUserAuthorizer).stop(); diff --git a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProvider.java b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProvider.java index b000659ec7..cd8ba8d6c3 100644 --- a/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProvider.java +++ b/cruise-control/src/main/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProvider.java @@ -25,6 +25,7 @@ public class TrustedProxySecurityProvider extends SpnegoSecurityProvider { private List _trustedProxyServices; private String _trustedProxyServicesIpRegex; private boolean _fallbackToSpnegoAllowed; + private List _spnegoPrincipalToLocalRules; private static final Logger LOG = LoggerFactory.getLogger(TrustedProxySecurityProvider.class); @@ -33,6 +34,7 @@ public void init(KafkaCruiseControlConfig config) { super.init(config); _trustedProxyServices = config.getList(WebServerConfig.TRUSTED_PROXY_SERVICES_CONFIG); _fallbackToSpnegoAllowed = config.getBoolean(WebServerConfig.TRUSTED_PROXY_SPNEGO_FALLBACK_ENABLED_CONFIG); + _spnegoPrincipalToLocalRules = config.getList(WebServerConfig.SPNEGO_PRINCIPAL_TO_LOCAL_RULES_CONFIG); String ipWhitelistRegex = config.getString(WebServerConfig.TRUSTED_PROXY_SERVICES_IP_REGEX_CONFIG); if (ipWhitelistRegex != null) { _trustedProxyServicesIpRegex = ipWhitelistRegex; @@ -43,8 +45,8 @@ public void init(KafkaCruiseControlConfig config) { @Override public LoginService loginService() { - TrustedProxyLoginService loginService = new TrustedProxyLoginService( - _spnegoPrincipal.realm(), authorizationService(), _trustedProxyServices, _trustedProxyServicesIpRegex, _fallbackToSpnegoAllowed); + TrustedProxyLoginService loginService = new TrustedProxyLoginService(_spnegoPrincipal.realm(), authorizationService(), + _trustedProxyServices, _trustedProxyServicesIpRegex, _fallbackToSpnegoAllowed, _spnegoPrincipalToLocalRules); loginService.setServiceName(_spnegoPrincipal.serviceName()); loginService.setHostName(_spnegoPrincipal.hostName()); loginService.setKeyTabPath(Paths.get(_keyTabPath)); diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoLoginServiceWithAuthServiceLifecycleTest.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoLoginServiceWithAuthServiceLifecycleTest.java new file mode 100644 index 0000000000..736ac66599 --- /dev/null +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoLoginServiceWithAuthServiceLifecycleTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ + +package com.linkedin.kafka.cruisecontrol.servlet.security.spnego; + +import org.apache.kafka.common.security.kerberos.KerberosShortNamer; +import org.eclipse.jetty.security.ConfigurableSpnegoLoginService; +import org.eclipse.jetty.security.SpnegoUserIdentity; +import org.eclipse.jetty.security.SpnegoUserPrincipal; +import org.eclipse.jetty.security.authentication.AuthorizationService; +import org.eclipse.jetty.server.UserIdentity; +import org.eclipse.jetty.server.UserIdentity.Scope; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; +import org.powermock.reflect.Whitebox; +import javax.security.auth.Subject; +import javax.servlet.http.HttpServletRequest; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.anyString; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.mock; +import static org.easymock.EasyMock.partialMockBuilder; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertEquals; +import static org.powermock.api.support.Stubber.stubMethod; + +/** + * Unit tests for {@link SpnegoLoginServiceWithAuthServiceLifecycle} + */ +@RunWith(PowerMockRunner.class) +@PowerMockIgnore({"javax.management.*", "org.ietf.jgss.GSSManager"}) +@PrepareForTest(SpnegoLoginServiceWithAuthServiceLifecycle.class) +public class SpnegoLoginServiceWithAuthServiceLifecycleTest { + public static final String USERNAME = "user1"; + private static final String REALM = "TEST_REALM"; + private static final String TOKEN = "TEST_TOKEN"; + private static final String ROLE = "ADMIN"; + private static final Subject SUBJECT = new Subject(); + private static final List ATL_RULES = Collections.singletonList("RULE:[1:$1@$0](.*@.*)s/@.*/foo/"); + private final AuthorizationService _mockAuthorizationService = mock(AuthorizationService.class); + private final ConfigurableSpnegoLoginService _mockLoginService = mock(ConfigurableSpnegoLoginService.class); + private final HttpServletRequest _mockRequest = mock(HttpServletRequest.class); + private final SpnegoUserIdentity _mockAuthIdentity = mock(SpnegoUserIdentity.class); + private final UserIdentity _mockRoleIdentity = mock(UserIdentity.class); + private final Scope _mockScope = mock(Scope.class); + private final GSSContext _mockGSSContext = mock(GSSContext.class); + + /** + * Init the unit test. + */ + @Before + public void setup() throws GSSException { + expect(_mockLoginService.login(anyString(), anyObject(), anyObject())).andReturn(_mockAuthIdentity); + expect(_mockAuthIdentity.getSubject()).andReturn(SUBJECT); + expect(_mockRoleIdentity.isUserInRole(ROLE, _mockScope)).andReturn(true); + } + + @Test + public void testExtractSpnegoContext() throws ReflectiveOperationException { + SpnegoLoginServiceWithAuthServiceLifecycle service = partialMockBuilder(SpnegoLoginServiceWithAuthServiceLifecycle.class).createMock(); + Whitebox.setInternalState(service, "_spnegoLoginService", _mockLoginService); + Class contextClass = Class.forName("org.eclipse.jetty.security.ConfigurableSpnegoLoginService$SpnegoContext"); + Constructor contextCtor = contextClass.getDeclaredConstructor(); + contextCtor.setAccessible(true); + Object context = contextCtor.newInstance(); + Field contextField = ConfigurableSpnegoLoginService.class.getDeclaredField("_context"); + contextField.setAccessible(true); + contextField.set(_mockLoginService, context); + replay(service); + + service.extractSpnegoContext(); + } + + @Test + public void testLoginWithoutKerberosRules() { + SpnegoLoginServiceWithAuthServiceLifecycle service = createAuthServiceWithMocking(new SpnegoUserPrincipal(USERNAME, TOKEN)); + replay(service, _mockLoginService, _mockAuthorizationService, _mockAuthIdentity, _mockRoleIdentity); + + UserIdentity userIdentity = service.login(USERNAME, new Object(), _mockRequest); + + assertUserIdentity(USERNAME, userIdentity); + } + + @Test + public void testLoginWithKerberosRules() { + String principalName = "user1@realm"; + String usernameReplaced = USERNAME + "foo"; + SpnegoUserPrincipal principal = new SpnegoUserPrincipal(principalName, TOKEN); + SpnegoLoginServiceWithAuthServiceLifecycle service = createAuthServiceWithMocking(principalName, usernameReplaced, principal); + Whitebox.setInternalState(service, "_kerberosShortNamer", KerberosShortNamer.fromUnparsedRules(REALM, ATL_RULES)); + replay(service, _mockLoginService, _mockAuthorizationService, _mockAuthIdentity, _mockRoleIdentity); + + UserIdentity userIdentity = service.login(principalName, new Object(), _mockRequest); + + assertUserIdentity(usernameReplaced, userIdentity); + } + + private SpnegoLoginServiceWithAuthServiceLifecycle createAuthServiceWithMocking(SpnegoUserPrincipal principal) { + return createAuthServiceWithMocking(USERNAME, USERNAME, principal); + } + + private SpnegoLoginServiceWithAuthServiceLifecycle createAuthServiceWithMocking(String name, String finalName, SpnegoUserPrincipal principal) { + SpnegoLoginServiceWithAuthServiceLifecycle service = partialMockBuilder(SpnegoLoginServiceWithAuthServiceLifecycle.class).createMock(); + stubMethod(SpnegoLoginServiceWithAuthServiceLifecycle.class, "getFullPrincipalFromGssContext", name); + stubMethod(SpnegoLoginServiceWithAuthServiceLifecycle.class, "addContext", _mockGSSContext); + + Whitebox.setInternalState(service, "_authorizationService", _mockAuthorizationService); + Whitebox.setInternalState(service, "_spnegoLoginService", _mockLoginService); + + expect(_mockAuthIdentity.getUserPrincipal()).andReturn(principal); + expect(_mockAuthorizationService.getUserIdentity(_mockRequest, finalName)).andReturn(_mockRoleIdentity); + + return service; + } + + private void assertUserIdentity(String username, UserIdentity userIdentity) { + assertEquals(username, userIdentity.getUserPrincipal().getName()); + assertEquals(SUBJECT, userIdentity.getSubject()); + userIdentity.isUserInRole(ROLE, _mockScope); + verify(_mockLoginService, _mockAuthorizationService, _mockRoleIdentity); + } + +} diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderIntegrationTest.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderIntegrationTest.java index 07734b0815..8695b2bd54 100644 --- a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderIntegrationTest.java +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderIntegrationTest.java @@ -9,20 +9,9 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; -import javax.security.auth.Subject; -import javax.servlet.http.HttpServletResponse; -import java.net.HttpURLConnection; -import java.net.URI; -import java.security.PrivilegedAction; - -import static com.linkedin.kafka.cruisecontrol.servlet.CruiseControlEndPoint.STATE; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; public class SpnegoSecurityProviderIntegrationTest extends SpnegoIntegrationTestHarness { - private static final String CRUISE_CONTROL_STATE_ENDPOINT = "kafkacruisecontrol/" + STATE; - public SpnegoSecurityProviderIntegrationTest() throws KrbException { } @@ -32,7 +21,7 @@ public SpnegoSecurityProviderIntegrationTest() throws KrbException { */ @Before public void setup() throws Exception { - super.start(); + start(); } /** @@ -40,40 +29,17 @@ public void setup() throws Exception { */ @After public void teardown() { - super.stop(); + stop(); } @Test public void testSuccessfulAuthentication() throws Exception { - Subject subject = _miniKdc.loginAs(CLIENT_PRINCIPAL); - Subject.doAs(subject, (PrivilegedAction) () -> { - try { - HttpURLConnection stateEndpointConnection = (HttpURLConnection) new URI(_app.serverUrl()) - .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); - assertEquals(HttpServletResponse.SC_OK, stateEndpointConnection.getResponseCode()); - } catch (Exception e) { - throw new RuntimeException(e); - } - return null; - }); + SpnegoSecurityProviderTestUtils.testSuccessfulAuthentication(_miniKdc, _app, CLIENT_PRINCIPAL); } @Test public void testNotAdminServiceLogin() throws Exception { - Subject subject = _miniKdc.loginAs(SOME_OTHER_SERVICE_PRINCIPAL); - Subject.doAs(subject, (PrivilegedAction) () -> { - HttpURLConnection stateEndpointConnection; - try { - stateEndpointConnection = (HttpURLConnection) new URI(_app.serverUrl()) - .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); - } catch (Exception e) { - throw new RuntimeException(e); - } - // There is a bug in the Jetty implementation and it doesn't seem to handle the connection - // properly in case of an error so it somehow doesn't send a response code. To work this around - // I catch the RuntimeException that it throws. - assertThrows(RuntimeException.class, stateEndpointConnection::getResponseCode); - return null; - }); + SpnegoSecurityProviderTestUtils.testNotAdminServiceLogin(_miniKdc, _app, SOME_OTHER_SERVICE_PRINCIPAL); } + } diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderTestUtils.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderTestUtils.java new file mode 100644 index 0000000000..8a726bda9f --- /dev/null +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderTestUtils.java @@ -0,0 +1,62 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ + +package com.linkedin.kafka.cruisecontrol.servlet.security.spnego; + +import com.linkedin.kafka.cruisecontrol.KafkaCruiseControlApp; +import com.linkedin.kafka.cruisecontrol.servlet.security.MiniKdc; +import javax.security.auth.Subject; +import javax.servlet.http.HttpServletResponse; +import java.net.HttpURLConnection; +import java.net.URI; +import java.security.PrivilegedAction; + +import static com.linkedin.kafka.cruisecontrol.servlet.CruiseControlEndPoint.STATE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** + * A test util class. + */ +public final class SpnegoSecurityProviderTestUtils { + + private static final String CRUISE_CONTROL_STATE_ENDPOINT = "kafkacruisecontrol/" + STATE; + + private SpnegoSecurityProviderTestUtils() { + + } + + public static void testSuccessfulAuthentication(MiniKdc miniKdc, KafkaCruiseControlApp app, String principal) throws Exception { + Subject subject = miniKdc.loginAs(principal); + Subject.doAs(subject, (PrivilegedAction) () -> { + try { + HttpURLConnection stateEndpointConnection = (HttpURLConnection) new URI(app.serverUrl()) + .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); + assertEquals(HttpServletResponse.SC_OK, stateEndpointConnection.getResponseCode()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return null; + }); + } + + public static void testNotAdminServiceLogin(MiniKdc miniKdc, KafkaCruiseControlApp app, String principal) throws Exception { + Subject subject = miniKdc.loginAs(principal); + Subject.doAs(subject, (PrivilegedAction) () -> { + HttpURLConnection stateEndpointConnection; + try { + stateEndpointConnection = (HttpURLConnection) new URI(app.serverUrl()) + .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); + } catch (Exception e) { + throw new RuntimeException(e); + } + // There is a bug in the Jetty implementation, and it doesn't seem to handle the connection + // properly in case of an error, so it somehow doesn't send a response code. To work this around + // I catch the RuntimeException that it throws. + assertThrows(RuntimeException.class, stateEndpointConnection::getResponseCode); + return null; + }); + } + +} diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderWithAtlRulesIntegrationTest.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderWithAtlRulesIntegrationTest.java new file mode 100644 index 0000000000..4252be6080 --- /dev/null +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/spnego/SpnegoSecurityProviderWithAtlRulesIntegrationTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ + +package com.linkedin.kafka.cruisecontrol.servlet.security.spnego; + +import com.linkedin.kafka.cruisecontrol.config.constants.WebServerConfig; +import com.linkedin.kafka.cruisecontrol.servlet.security.SpnegoIntegrationTestHarness; +import org.apache.kerby.kerberos.kerb.KrbException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class SpnegoSecurityProviderWithAtlRulesIntegrationTest extends SpnegoIntegrationTestHarness { + + private static final String AUTH_ATL_RULES_CREDENTIALS_FILE = "auth-atlrules.credentials"; + private static final List ATL_RULES = Collections.singletonList("RULE:[2:$1@$0](.*@.*)s/@.*/foo/"); + + public SpnegoSecurityProviderWithAtlRulesIntegrationTest() throws KrbException { + } + + /** + * Initializes the test environment. + * @throws Exception + */ + @Before + public void setup() throws Exception { + start(); + } + + /** + * Stops the test environment. + */ + @After + public void teardown() { + stop(); + } + + @Override + protected Map withConfigs() { + Map configs = super.withConfigs(); + configs.put(WebServerConfig.SPNEGO_PRINCIPAL_TO_LOCAL_RULES_CONFIG, ATL_RULES); + configs.put(WebServerConfig.WEBSERVER_AUTH_CREDENTIALS_FILE_CONFIG, + Objects.requireNonNull(getClass().getClassLoader().getResource(AUTH_ATL_RULES_CREDENTIALS_FILE)).getPath()); + return configs; + } + + @Test + public void testSuccessfulAuthentication() throws Exception { + SpnegoSecurityProviderTestUtils.testSuccessfulAuthentication(_miniKdc, _app, CLIENT_PRINCIPAL + '@' + REALM); + } + + @Test + public void testNotAdminServiceLogin() throws Exception { + SpnegoSecurityProviderTestUtils.testNotAdminServiceLogin(_miniKdc, _app, SOME_OTHER_SERVICE_PRINCIPAL + '@' + REALM); + } + +} diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxyLoginServiceTest.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxyLoginServiceTest.java index 4a146a928b..8db3ca8e67 100644 --- a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxyLoginServiceTest.java +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxyLoginServiceTest.java @@ -50,77 +50,71 @@ public UserIdentity getUserIdentity(HttpServletRequest request, String name) { } } + private final SpnegoLoginServiceWithAuthServiceLifecycle _mockSpnegoLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); + private final SpnegoLoginServiceWithAuthServiceLifecycle _mockFallbackLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); + @Test public void testSuccessfulAuthentication() { - SpnegoLoginServiceWithAuthServiceLifecycle mockSpnegoLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); - SpnegoLoginServiceWithAuthServiceLifecycle mockFallbackLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); - SpnegoUserPrincipal servicePrincipal = new SpnegoUserPrincipal(TEST_SERVICE_USER, ENCODED_TOKEN); UserIdentity serviceDelegate = mock(UserIdentity.class); Subject subject = new Subject(true, Collections.singleton(servicePrincipal), Collections.emptySet(), Collections.emptySet()); SpnegoUserIdentity result = new SpnegoUserIdentity(subject, servicePrincipal, serviceDelegate); - expect(mockSpnegoLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); + expect(_mockSpnegoLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); TestAuthorizer userAuthorizer = new TestAuthorizer(TEST_USER); HttpServletRequest mockRequest = mock(HttpServletRequest.class); expect(mockRequest.getParameter(DO_AS)).andReturn(TEST_USER); - replay(mockSpnegoLoginService, mockRequest); + replay(_mockSpnegoLoginService, mockRequest); - TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(mockSpnegoLoginService, mockFallbackLoginService, + TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(_mockSpnegoLoginService, _mockFallbackLoginService, userAuthorizer, false); UserIdentity doAsIdentity = trustedProxyLoginService.login(null, ENCODED_TOKEN, mockRequest); assertNotNull(doAsIdentity); assertNotNull(doAsIdentity.getUserPrincipal()); assertEquals(doAsIdentity.getUserPrincipal().getName(), TEST_USER); assertEquals(((TrustedProxyPrincipal) doAsIdentity.getUserPrincipal()).servicePrincipal(), servicePrincipal); - verify(mockSpnegoLoginService, mockRequest); + verify(_mockSpnegoLoginService, mockRequest); } @Test public void testNoDoAsUser() { - SpnegoLoginServiceWithAuthServiceLifecycle mockSpnegoLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); - SpnegoLoginServiceWithAuthServiceLifecycle mockFallbackLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); - SpnegoUserPrincipal servicePrincipal = new SpnegoUserPrincipal(TEST_SERVICE_USER, ENCODED_TOKEN); UserIdentity serviceDelegate = mock(UserIdentity.class); Subject subject = new Subject(true, Collections.singleton(servicePrincipal), Collections.emptySet(), Collections.emptySet()); SpnegoUserIdentity result = new SpnegoUserIdentity(subject, servicePrincipal, serviceDelegate); - expect(mockSpnegoLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); + expect(_mockSpnegoLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); TestAuthorizer userAuthorizer = new TestAuthorizer(TEST_USER); HttpServletRequest mockRequest = mock(HttpServletRequest.class); - replay(mockSpnegoLoginService); + replay(_mockSpnegoLoginService); - TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(mockSpnegoLoginService, mockFallbackLoginService, + TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(_mockSpnegoLoginService, _mockFallbackLoginService, userAuthorizer, false); UserIdentity doAsIdentity = trustedProxyLoginService.login(null, ENCODED_TOKEN, mockRequest); assertNotNull(doAsIdentity); assertNotNull(doAsIdentity.getUserPrincipal()); assertNull(doAsIdentity.getUserPrincipal().getName()); assertFalse(((SpnegoUserIdentity) doAsIdentity).isEstablished()); - verify(mockSpnegoLoginService); + verify(_mockSpnegoLoginService); } @Test public void testInvalidAuthServiceUser() { - SpnegoLoginServiceWithAuthServiceLifecycle mockSpnegoLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); - SpnegoLoginServiceWithAuthServiceLifecycle mockFallbackLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); - SpnegoUserPrincipal servicePrincipal = new SpnegoUserPrincipal(TEST_SERVICE_USER, ENCODED_TOKEN); Subject subject = new Subject(true, Collections.singleton(servicePrincipal), Collections.emptySet(), Collections.emptySet()); SpnegoUserIdentity result = new SpnegoUserIdentity(subject, servicePrincipal, null); - expect(mockSpnegoLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); + expect(_mockSpnegoLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); TestAuthorizer userAuthorizer = new TestAuthorizer(TEST_USER); HttpServletRequest mockRequest = mock(HttpServletRequest.class); expect(mockRequest.getParameter(DO_AS)).andReturn(TEST_USER); - replay(mockSpnegoLoginService); + replay(_mockSpnegoLoginService); - TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(mockSpnegoLoginService, mockFallbackLoginService, + TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(_mockSpnegoLoginService, _mockFallbackLoginService, userAuthorizer, false); UserIdentity doAsIdentity = trustedProxyLoginService.login(null, ENCODED_TOKEN, mockRequest); assertNotNull(doAsIdentity); @@ -129,27 +123,77 @@ public void testInvalidAuthServiceUser() { @Test public void testFallbackToSpnego() { - SpnegoLoginServiceWithAuthServiceLifecycle mockSpnegoLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); - SpnegoLoginServiceWithAuthServiceLifecycle mockFallbackLoginService = mock(SpnegoLoginServiceWithAuthServiceLifecycle.class); - SpnegoUserPrincipal servicePrincipal = new SpnegoUserPrincipal(TEST_SERVICE_USER, ENCODED_TOKEN); UserIdentity serviceDelegate = mock(UserIdentity.class); Subject subject = new Subject(true, Collections.singleton(servicePrincipal), Collections.emptySet(), Collections.emptySet()); SpnegoUserIdentity result = new SpnegoUserIdentity(subject, servicePrincipal, serviceDelegate); - expect(mockFallbackLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); + expect(_mockFallbackLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); TestAuthorizer userAuthorizer = new TestAuthorizer(TEST_USER); HttpServletRequest mockRequest = mock(HttpServletRequest.class); - replay(mockFallbackLoginService); + replay(_mockFallbackLoginService); - TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(mockSpnegoLoginService, mockFallbackLoginService, + TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(_mockSpnegoLoginService, _mockFallbackLoginService, userAuthorizer, true); UserIdentity doAsIdentity = trustedProxyLoginService.login(null, ENCODED_TOKEN, mockRequest); assertNotNull(doAsIdentity); assertNotNull(doAsIdentity.getUserPrincipal()); assertEquals(servicePrincipal, doAsIdentity.getUserPrincipal()); + verify(_mockFallbackLoginService); + } + + @Test + public void testTrustedProxyWithKerberosRules() { + String username = "user1"; + String proxy = "proxy2@realm"; + SpnegoUserPrincipal servicePrincipal = new SpnegoUserPrincipal(proxy, ENCODED_TOKEN); + UserIdentity serviceDelegate = mock(UserIdentity.class); + Subject subject = new Subject(true, Collections.singleton(servicePrincipal), Collections.emptySet(), Collections.emptySet()); + SpnegoUserIdentity result = new SpnegoUserIdentity(subject, servicePrincipal, serviceDelegate); + expect(_mockSpnegoLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); + + TestAuthorizer userAuthorizer = new TestAuthorizer(username); + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + expect(mockRequest.getParameter(DO_AS)).andReturn(username); + replay(_mockSpnegoLoginService, mockRequest); + TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(_mockSpnegoLoginService, _mockFallbackLoginService, + userAuthorizer, false); + + UserIdentity doAsIdentity = trustedProxyLoginService.login(proxy, ENCODED_TOKEN, mockRequest); + + assertNotNull(doAsIdentity); + assertNotNull(doAsIdentity.getUserPrincipal()); + assertEquals(doAsIdentity.getUserPrincipal().getName(), username); + assertEquals(((TrustedProxyPrincipal) doAsIdentity.getUserPrincipal()).servicePrincipal(), servicePrincipal); + verify(_mockSpnegoLoginService, mockRequest); + } + + @Test + public void testFallbackToSpnegoWithKerberosRules() { + String username = "user1"; + String principal = "user1@realm"; + String usernameReplaced = username + "foo"; + SpnegoUserPrincipal servicePrincipal = new SpnegoUserPrincipal(usernameReplaced, ENCODED_TOKEN); + UserIdentity serviceDelegate = mock(UserIdentity.class); + Subject subject = new Subject(true, Collections.singleton(servicePrincipal), Collections.emptySet(), Collections.emptySet()); + SpnegoUserIdentity result = new SpnegoUserIdentity(subject, servicePrincipal, serviceDelegate); + expect(_mockFallbackLoginService.login(anyString(), anyObject(), anyObject())).andReturn(result); + + TestAuthorizer userAuthorizer = new TestAuthorizer(username); + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + replay(_mockFallbackLoginService); + TrustedProxyLoginService trustedProxyLoginService = new TrustedProxyLoginService(_mockSpnegoLoginService, _mockFallbackLoginService, + userAuthorizer, true); + + UserIdentity doAsIdentity = trustedProxyLoginService.login(principal, ENCODED_TOKEN, mockRequest); + + assertNotNull(doAsIdentity); + assertNotNull(doAsIdentity.getUserPrincipal()); + SpnegoUserPrincipal doAsPrincipal = (SpnegoUserPrincipal) doAsIdentity.getUserPrincipal(); + assertEquals(servicePrincipal.getName(), doAsPrincipal.getName()); assertTrue(((SpnegoUserIdentity) doAsIdentity).isEstablished()); - verify(mockFallbackLoginService); + verify(_mockFallbackLoginService); } + } diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderIntegrationTest.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderIntegrationTest.java index d9c8dad597..4ce8cc2f1f 100644 --- a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderIntegrationTest.java +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderIntegrationTest.java @@ -10,23 +10,11 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; -import javax.security.auth.Subject; -import javax.servlet.http.HttpServletResponse; -import java.net.HttpURLConnection; -import java.net.URI; -import java.security.PrivilegedAction; import java.util.List; import java.util.Map; -import static com.linkedin.kafka.cruisecontrol.servlet.CruiseControlEndPoint.STATE; -import static com.linkedin.kafka.cruisecontrol.servlet.parameters.ParameterUtils.DO_AS; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; - public class TrustedProxySecurityProviderIntegrationTest extends SpnegoIntegrationTestHarness { - private static final String CRUISE_CONTROL_STATE_ENDPOINT = "kafkacruisecontrol/" + STATE; - private static final String AUTH_SERVICE_NAME = "testauthservice"; private static final String AUTH_SERVICE_PRINCIPAL = AUTH_SERVICE_NAME + "/localhost"; @@ -55,7 +43,7 @@ protected Map withConfigs() { */ @Before public void setup() throws Exception { - super.start(); + start(); } /** @@ -63,63 +51,21 @@ public void setup() throws Exception { */ @After public void teardown() { - super.stop(); + stop(); } @Test public void testSuccessfulAuthentication() throws Exception { - Subject subject = _miniKdc.loginAs(AUTH_SERVICE_PRINCIPAL); - Subject.doAs(subject, (PrivilegedAction) () -> { - try { - String endpoint = CRUISE_CONTROL_STATE_ENDPOINT + "?" + DO_AS + "=" + CC_TEST_ADMIN; - HttpURLConnection stateEndpointConnection = (HttpURLConnection) new URI(_app.serverUrl()) - .resolve(endpoint).toURL().openConnection(); - assertEquals(HttpServletResponse.SC_OK, stateEndpointConnection.getResponseCode()); - } catch (Exception e) { - throw new RuntimeException(e); - } - return null; - }); + TrustedProxySecurityProviderTestUtils.testSuccessfulAuthentication(_miniKdc, _app, AUTH_SERVICE_PRINCIPAL, CC_TEST_ADMIN); } @Test public void testNoDoAsParameter() throws Exception { - Subject subject = _miniKdc.loginAs(AUTH_SERVICE_PRINCIPAL); - Subject.doAs(subject, (PrivilegedAction) () -> { - - HttpURLConnection stateEndpointConnection; - try { - stateEndpointConnection = (HttpURLConnection) new URI(_app.serverUrl()) - .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); - } catch (Exception e) { - throw new RuntimeException(e); - } - // There is a bug in the Jetty implementation and it doesn't seem to handle the connection - // properly in case of an error so it somehow doesn't send a response code. To work this around - // I catch the RuntimeException that it throws. - assertThrows(RuntimeException.class, stateEndpointConnection::getResponseCode); - return null; - }); + TrustedProxySecurityProviderTestUtils.testNoDoAsParameter(_miniKdc, _app, AUTH_SERVICE_PRINCIPAL); } @Test public void testNotAdminServiceLogin() throws Exception { - Subject subject = _miniKdc.loginAs(SOME_OTHER_SERVICE_PRINCIPAL); - Subject.doAs(subject, (PrivilegedAction) () -> { - - String endpoint = CRUISE_CONTROL_STATE_ENDPOINT + "?" + DO_AS + "=" + CC_TEST_ADMIN; - HttpURLConnection stateEndpointConnection; - try { - stateEndpointConnection = (HttpURLConnection) new URI(_app.serverUrl()) - .resolve(endpoint).toURL().openConnection(); - } catch (Exception e) { - throw new RuntimeException(e); - } - // There is a bug in the Jetty implementation and it doesn't seem to handle the connection - // properly in case of an error so it somehow doesn't send a response code. To work this around - // I catch the RuntimeException that it throws. - assertThrows(RuntimeException.class, stateEndpointConnection::getResponseCode); - return null; - }); + TrustedProxySecurityProviderTestUtils.testNotAdminServiceLogin(_miniKdc, _app, SOME_OTHER_SERVICE_PRINCIPAL, CC_TEST_ADMIN); } } diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderSpnegoFallbackIntegrationTest.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderSpnegoFallbackIntegrationTest.java index 6278ce0d86..649b0a78c8 100644 --- a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderSpnegoFallbackIntegrationTest.java +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderSpnegoFallbackIntegrationTest.java @@ -10,22 +10,11 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; -import javax.security.auth.Subject; -import javax.servlet.http.HttpServletResponse; -import java.net.HttpURLConnection; -import java.net.URI; -import java.security.PrivilegedAction; import java.util.List; import java.util.Map; -import static com.linkedin.kafka.cruisecontrol.servlet.CruiseControlEndPoint.STATE; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; - public class TrustedProxySecurityProviderSpnegoFallbackIntegrationTest extends SpnegoIntegrationTestHarness { - private static final String CRUISE_CONTROL_STATE_ENDPOINT = "kafkacruisecontrol/" + STATE; - private static final String AUTH_SERVICE_NAME = "testauthservice"; private static final String AUTH_SERVICE_PRINCIPAL = AUTH_SERVICE_NAME + "/localhost"; private static final String TEST_USERNAME = "ccTestUser"; @@ -59,7 +48,7 @@ protected Map withConfigs() { */ @Before public void setup() throws Exception { - super.start(); + start(); } /** @@ -67,43 +56,16 @@ public void setup() throws Exception { */ @After public void teardown() { - super.stop(); + stop(); } @Test public void testSuccessfulAuthentication() throws Exception { - Subject subject = _miniKdc.loginAs(TEST_USERNAME_PRINCIPAL); - Subject.doAs(subject, (PrivilegedAction) () -> { - - HttpURLConnection stateEndpointConnection; - try { - stateEndpointConnection = (HttpURLConnection) new URI(_app.serverUrl()) - .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); - assertEquals(HttpServletResponse.SC_OK, stateEndpointConnection.getResponseCode()); - } catch (Exception e) { - throw new RuntimeException(e); - } - return null; - }); + TrustedProxySecurityProviderTestUtils.testSuccessfulFallbackAuthentication(_miniKdc, _app, TEST_USERNAME_PRINCIPAL); } @Test public void testUnsuccessfulAuthentication() throws Exception { - Subject subject = _miniKdc.loginAs(SOME_OTHER_SERVICE_PRINCIPAL); - Subject.doAs(subject, (PrivilegedAction) () -> { - - HttpURLConnection stateEndpointConnection; - try { - stateEndpointConnection = (HttpURLConnection) new URI(_app.serverUrl()) - .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); - } catch (Exception e) { - throw new RuntimeException(e); - } - // There is a bug in the Jetty implementation and it doesn't seem to handle the connection - // properly in case of an error so it somehow doesn't send a response code. To work this around - // I catch the RuntimeException that it throws. - assertThrows(RuntimeException.class, stateEndpointConnection::getResponseCode); - return null; - }); + TrustedProxySecurityProviderTestUtils.testUnsuccessfulFallbackAuthentication(_miniKdc, _app, SOME_OTHER_SERVICE_PRINCIPAL); } } diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderSpnegoFallbackWithAtlRulesIntegrationTest.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderSpnegoFallbackWithAtlRulesIntegrationTest.java new file mode 100644 index 0000000000..3bd0dbf38b --- /dev/null +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderSpnegoFallbackWithAtlRulesIntegrationTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ + +package com.linkedin.kafka.cruisecontrol.servlet.security.trustedproxy; + +import com.linkedin.kafka.cruisecontrol.config.constants.WebServerConfig; +import com.linkedin.kafka.cruisecontrol.servlet.security.SpnegoIntegrationTestHarness; +import org.apache.kerby.kerberos.kerb.KrbException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class TrustedProxySecurityProviderSpnegoFallbackWithAtlRulesIntegrationTest extends SpnegoIntegrationTestHarness { + + private static final String AUTH_ATL_RULES_CREDENTIALS_FILE = "auth-atlrules.credentials"; + private static final List ATL_RULES = Collections.singletonList("RULE:[2:$1@$0](.*@.*)s/@.*/foo/"); + private static final String AUTH_SERVICE_NAME = "testauthservice"; + private static final String AUTH_SERVICE_PRINCIPAL = AUTH_SERVICE_NAME + "/localhost"; + private static final String TEST_USERNAME = "ccTestUser"; + private static final String TEST_USERNAME_PRINCIPAL = TEST_USERNAME + "/localhost"; + + public TrustedProxySecurityProviderSpnegoFallbackWithAtlRulesIntegrationTest() throws KrbException { + } + + @Override + public List principals() { + List principals = super.principals(); + principals.add(AUTH_SERVICE_PRINCIPAL); + principals.add(TEST_USERNAME_PRINCIPAL); + return principals; + } + + @Override + protected Map withConfigs() { + Map configs = super.withConfigs(); + configs.put(WebServerConfig.WEBSERVER_SECURITY_PROVIDER_CONFIG, TrustedProxySecurityProvider.class); + configs.put(WebServerConfig.TRUSTED_PROXY_SERVICES_CONFIG, AUTH_SERVICE_NAME); + configs.put(WebServerConfig.TRUSTED_PROXY_SPNEGO_FALLBACK_ENABLED_CONFIG, true); + configs.put(WebServerConfig.SPNEGO_PRINCIPAL_TO_LOCAL_RULES_CONFIG, ATL_RULES); + configs.put(WebServerConfig.WEBSERVER_AUTH_CREDENTIALS_FILE_CONFIG, + Objects.requireNonNull(getClass().getClassLoader().getResource(AUTH_ATL_RULES_CREDENTIALS_FILE)).getPath()); + + return configs; + } + + /** + * Initializes the test environment. + * @throws Exception + */ + @Before + public void setup() throws Exception { + start(); + } + + /** + * Stops the test environment. + */ + @After + public void teardown() { + stop(); + } + + @Test + public void testSuccessfulAuthentication() throws Exception { + TrustedProxySecurityProviderTestUtils.testSuccessfulFallbackAuthentication(_miniKdc, _app, TEST_USERNAME_PRINCIPAL + '@' + REALM); + } + + @Test + public void testUnsuccessfulAuthentication() throws Exception { + TrustedProxySecurityProviderTestUtils.testUnsuccessfulFallbackAuthentication(_miniKdc, _app, SOME_OTHER_SERVICE_PRINCIPAL + '@' + REALM); + } + +} diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderTestUtils.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderTestUtils.java new file mode 100644 index 0000000000..f8e43975c6 --- /dev/null +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderTestUtils.java @@ -0,0 +1,119 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ +package com.linkedin.kafka.cruisecontrol.servlet.security.trustedproxy; + +import com.linkedin.kafka.cruisecontrol.KafkaCruiseControlApp; +import com.linkedin.kafka.cruisecontrol.servlet.security.MiniKdc; +import javax.security.auth.Subject; +import javax.servlet.http.HttpServletResponse; +import java.net.HttpURLConnection; +import java.net.URI; +import java.security.PrivilegedAction; + +import static com.linkedin.kafka.cruisecontrol.servlet.CruiseControlEndPoint.STATE; +import static com.linkedin.kafka.cruisecontrol.servlet.parameters.ParameterUtils.DO_AS; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +/** + * A test util class. + */ +public final class TrustedProxySecurityProviderTestUtils { + + private static final String CRUISE_CONTROL_STATE_ENDPOINT = "kafkacruisecontrol/" + STATE; + + private TrustedProxySecurityProviderTestUtils() { + + } + + public static void testSuccessfulAuthentication(MiniKdc miniKdc, KafkaCruiseControlApp app, String principal, String doAs) throws Exception { + Subject subject = miniKdc.loginAs(principal); + Subject.doAs(subject, (PrivilegedAction) () -> { + try { + String endpoint = CRUISE_CONTROL_STATE_ENDPOINT + '?' + DO_AS + '=' + doAs; + HttpURLConnection stateEndpointConnection = (HttpURLConnection) new URI(app.serverUrl()) + .resolve(endpoint).toURL().openConnection(); + assertEquals(HttpServletResponse.SC_OK, stateEndpointConnection.getResponseCode()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return null; + }); + } + + public static void testNoDoAsParameter(MiniKdc miniKdc, KafkaCruiseControlApp app, String principal) throws Exception { + Subject subject = miniKdc.loginAs(principal); + Subject.doAs(subject, (PrivilegedAction) () -> { + + HttpURLConnection stateEndpointConnection; + try { + stateEndpointConnection = (HttpURLConnection) new URI(app.serverUrl()) + .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); + } catch (Exception e) { + throw new RuntimeException(e); + } + // There is a bug in the Jetty implementation, and it doesn't seem to handle the connection + // properly in case of an error, so it somehow doesn't send a response code. To work this around + // I catch the RuntimeException that it throws. + assertThrows(RuntimeException.class, stateEndpointConnection::getResponseCode); + return null; + }); + } + + public static void testNotAdminServiceLogin(MiniKdc miniKdc, KafkaCruiseControlApp app, String principal, String doAs) throws Exception { + Subject subject = miniKdc.loginAs(principal); + Subject.doAs(subject, (PrivilegedAction) () -> { + + String endpoint = CRUISE_CONTROL_STATE_ENDPOINT + '?' + DO_AS + '=' + doAs; + HttpURLConnection stateEndpointConnection; + try { + stateEndpointConnection = (HttpURLConnection) new URI(app.serverUrl()) + .resolve(endpoint).toURL().openConnection(); + } catch (Exception e) { + throw new RuntimeException(e); + } + // There is a bug in the Jetty implementation, and it doesn't seem to handle the connection + // properly in case of an error, so it somehow doesn't send a response code. To work this around + // I catch the RuntimeException that it throws. + assertThrows(RuntimeException.class, stateEndpointConnection::getResponseCode); + return null; + }); + } + + public static void testSuccessfulFallbackAuthentication(MiniKdc miniKdc, KafkaCruiseControlApp app, String principal) throws Exception { + Subject subject = miniKdc.loginAs(principal); + Subject.doAs(subject, (PrivilegedAction) () -> { + + HttpURLConnection stateEndpointConnection; + try { + stateEndpointConnection = (HttpURLConnection) new URI(app.serverUrl()) + .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); + assertEquals(HttpServletResponse.SC_OK, stateEndpointConnection.getResponseCode()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return null; + }); + } + + public static void testUnsuccessfulFallbackAuthentication(MiniKdc miniKdc, KafkaCruiseControlApp app, String principal) throws Exception { + Subject subject = miniKdc.loginAs(principal); + Subject.doAs(subject, (PrivilegedAction) () -> { + + HttpURLConnection stateEndpointConnection; + try { + stateEndpointConnection = (HttpURLConnection) new URI(app.serverUrl()) + .resolve(CRUISE_CONTROL_STATE_ENDPOINT).toURL().openConnection(); + } catch (Exception e) { + throw new RuntimeException(e); + } + // There is a bug in the Jetty implementation, and it doesn't seem to handle the connection + // properly in case of an error, so it somehow doesn't send a response code. To work this around + // I catch the RuntimeException that it throws. + assertThrows(RuntimeException.class, stateEndpointConnection::getResponseCode); + return null; + }); + } + +} diff --git a/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderWithAtlRulesIntegrationTest.java b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderWithAtlRulesIntegrationTest.java new file mode 100644 index 0000000000..495317b219 --- /dev/null +++ b/cruise-control/src/test/java/com/linkedin/kafka/cruisecontrol/servlet/security/trustedproxy/TrustedProxySecurityProviderWithAtlRulesIntegrationTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2023 LinkedIn Corp. Licensed under the BSD 2-Clause License (the "License"). See License in the project root for license information. + */ + +package com.linkedin.kafka.cruisecontrol.servlet.security.trustedproxy; + +import com.linkedin.kafka.cruisecontrol.config.constants.WebServerConfig; +import com.linkedin.kafka.cruisecontrol.servlet.security.SpnegoIntegrationTestHarness; +import org.apache.kerby.kerberos.kerb.KrbException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class TrustedProxySecurityProviderWithAtlRulesIntegrationTest extends SpnegoIntegrationTestHarness { + + private static final String AUTH_ATL_RULES_CREDENTIALS_FILE = "auth-atlrules.credentials"; + private static final List ATL_RULES = Collections.singletonList("RULE:[2:$1@$0](.*@.*)s/@.*/foo/"); + private static final String AUTH_SERVICE_NAME = "testauthservice"; + private static final String AUTH_SERVICE_PRINCIPAL = AUTH_SERVICE_NAME + "/localhost"; + + public TrustedProxySecurityProviderWithAtlRulesIntegrationTest() throws KrbException { + } + + @Override + public List principals() { + List principals = super.principals(); + principals.add(AUTH_SERVICE_PRINCIPAL); + return principals; + } + + @Override + protected Map withConfigs() { + Map configs = super.withConfigs(); + configs.put(WebServerConfig.WEBSERVER_SECURITY_PROVIDER_CONFIG, TrustedProxySecurityProvider.class); + configs.put(WebServerConfig.TRUSTED_PROXY_SERVICES_CONFIG, AUTH_SERVICE_NAME + "foo"); + configs.put(WebServerConfig.SPNEGO_PRINCIPAL_TO_LOCAL_RULES_CONFIG, ATL_RULES); + + return configs; + } + + /** + * Initializes the test environment. + * @throws Exception + */ + @Before + public void setup() throws Exception { + start(); + } + + /** + * Stops the test environment. + */ + @After + public void teardown() { + stop(); + } + + @Test + public void testSuccessfulAuthentication() throws Exception { + TrustedProxySecurityProviderTestUtils.testSuccessfulAuthentication(_miniKdc, _app, AUTH_SERVICE_PRINCIPAL + '@' + REALM, CC_TEST_ADMIN); + } + + @Test + public void testNoDoAsParameter() throws Exception { + TrustedProxySecurityProviderTestUtils.testNoDoAsParameter(_miniKdc, _app, AUTH_SERVICE_PRINCIPAL + '@' + REALM); + } + + @Test + public void testNotAdminServiceLogin() throws Exception { + TrustedProxySecurityProviderTestUtils.testNotAdminServiceLogin(_miniKdc, _app, SOME_OTHER_SERVICE_PRINCIPAL + '@' + REALM, CC_TEST_ADMIN); + } +} diff --git a/cruise-control/src/test/resources/auth-atlrules.credentials b/cruise-control/src/test/resources/auth-atlrules.credentials new file mode 100644 index 0000000000..5bce434db6 --- /dev/null +++ b/cruise-control/src/test/resources/auth-atlrules.credentials @@ -0,0 +1,2 @@ +ccTestAdminfoo: ,ADMIN +ccTestUserfoo: ,USER \ No newline at end of file diff --git a/docs/wiki/User Guide/Security.md b/docs/wiki/User Guide/Security.md index 60d981b186..0a3860e662 100644 --- a/docs/wiki/User Guide/Security.md +++ b/docs/wiki/User Guide/Security.md @@ -98,6 +98,18 @@ trusted.proxy.services=service1,service2 The difference in this case is that the `webserver.auth.credentials.file` config stores the end-user credentials and not the trusted proxy credentials. These are listed in the `trusted.proxy.services` config. +``` +spnego.principal.to.local.rules= +``` +A list of rules for mapping from principal names to short names (typically operating system usernames). The rules are +evaluated in order and the first rule that matches a principal name is used to map it to a short name. Any later rules +in the list are ignored. By default, principal names of the form {username}/{hostname}@{REALM} are mapped to {username}. +When not specified, the short name will be used. + +Use auth-to-local (ATL) rules to ensure only principals containing hostnames of the specific cluster are mapped to +legitimate users. Without ATL rules different clusters' users can be mapped to the same legitimate user, if the two +different users are part of the same Kerberos realm. + ## HTTPS HTTPS can be configured with the following configs: