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