diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java b/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java index e50e18a90a..28c7e95012 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/AmoroManagementConf.java @@ -21,11 +21,13 @@ import org.apache.amoro.config.ConfigOption; import org.apache.amoro.config.ConfigOptions; import org.apache.amoro.server.authentication.DefaultPasswdAuthenticationProvider; +import org.apache.amoro.server.authorization.Role; import org.apache.amoro.utils.MemorySize; import java.time.Duration; import java.util.Arrays; import java.util.List; +import java.util.Map; public class AmoroManagementConf { @@ -53,6 +55,71 @@ public class AmoroManagementConf { .defaultValue("admin") .withDescription("The administrator password"); + public static final ConfigOption AUTHORIZATION_ENABLED = + ConfigOptions.key("http-server.authorization.enabled") + .booleanType() + .defaultValue(false) + .withDescription("Whether to enable dashboard RBAC authorization."); + + public static final ConfigOption AUTHORIZATION_DEFAULT_ROLE = + ConfigOptions.key("http-server.authorization.default-role") + .enumType(Role.class) + .defaultValue(Role.READ_ONLY) + .withDescription( + "Default role for authenticated users without an explicit role mapping."); + + public static final ConfigOption> AUTHORIZATION_ADMIN_USERS = + ConfigOptions.key("http-server.authorization.admin-users") + .stringType() + .asList() + .defaultValues() + .withDescription("Additional usernames that should always be treated as admin users."); + + public static final ConfigOption>> AUTHORIZATION_USERS = + ConfigOptions.key("http-server.authorization.users") + .mapType() + .asList() + .noDefaultValue() + .withDescription("Local dashboard users with username/password/role entries."); + + public static final ConfigOption AUTHORIZATION_LDAP_ROLE_MAPPING_ENABLED = + ConfigOptions.key("http-server.authorization.ldap-role-mapping.enabled") + .booleanType() + .defaultValue(false) + .withDescription("Whether to resolve dashboard roles from LDAP group membership."); + + public static final ConfigOption AUTHORIZATION_LDAP_ROLE_MAPPING_ADMIN_GROUP_DN = + ConfigOptions.key("http-server.authorization.ldap-role-mapping.admin-group-dn") + .stringType() + .noDefaultValue() + .withDescription( + "Full DN of the LDAP admin group, e.g. CN=amoro-admins,OU=Groups,DC=example,DC=com."); + + public static final ConfigOption AUTHORIZATION_LDAP_ROLE_MAPPING_GROUP_MEMBER_ATTRIBUTE = + ConfigOptions.key("http-server.authorization.ldap-role-mapping.group-member-attribute") + .stringType() + .defaultValue("member") + .withDescription("LDAP group attribute that stores member references."); + + public static final ConfigOption AUTHORIZATION_LDAP_ROLE_MAPPING_USER_DN_PATTERN = + ConfigOptions.key("http-server.authorization.ldap-role-mapping.user-dn-pattern") + .stringType() + .noDefaultValue() + .withDescription( + "LDAP user DN pattern used to match group members. Use {0} as the username placeholder."); + + public static final ConfigOption AUTHORIZATION_LDAP_ROLE_MAPPING_BIND_DN = + ConfigOptions.key("http-server.authorization.ldap-role-mapping.bind-dn") + .stringType() + .defaultValue("") + .withDescription("Optional LDAP bind DN used when querying role-mapping groups."); + + public static final ConfigOption AUTHORIZATION_LDAP_ROLE_MAPPING_BIND_PASSWORD = + ConfigOptions.key("http-server.authorization.ldap-role-mapping.bind-password") + .stringType() + .defaultValue("") + .withDescription("Optional LDAP bind password used when querying role-mapping groups."); + /** Enable master & slave mode, which supports horizontal scaling of AMS. */ public static final ConfigOption USE_MASTER_SLAVE_MODE = ConfigOptions.key("use-master-slave-mode") diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/authentication/DefaultPasswdAuthenticationProvider.java b/amoro-ams/src/main/java/org/apache/amoro/server/authentication/DefaultPasswdAuthenticationProvider.java index 2a74a4ea71..4096808a11 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/authentication/DefaultPasswdAuthenticationProvider.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/authentication/DefaultPasswdAuthenticationProvider.java @@ -24,22 +24,70 @@ import org.apache.amoro.config.Configurations; import org.apache.amoro.exception.SignatureCheckException; import org.apache.amoro.server.AmoroManagementConf; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; public class DefaultPasswdAuthenticationProvider implements PasswdAuthenticationProvider { - private String basicAuthUser; - private String basicAuthPassword; + private static final Logger LOG = + LoggerFactory.getLogger(DefaultPasswdAuthenticationProvider.class); + + private final String basicAuthUser; + private final String basicAuthPassword; + private final Map localUsers; public DefaultPasswdAuthenticationProvider(Configurations conf) { this.basicAuthUser = conf.get(AmoroManagementConf.ADMIN_USERNAME); this.basicAuthPassword = conf.get(AmoroManagementConf.ADMIN_PASSWORD); + this.localUsers = loadLocalUsers(conf); } @Override public BasicPrincipal authenticate(PasswordCredential credential) throws SignatureCheckException { + String localPassword = localUsers.get(credential.username()); + if (localPassword != null) { + if (localPassword.equals(credential.password())) { + return new BasicPrincipal(credential.username()); + } + throw new SignatureCheckException("Invalid password for user: " + credential.username()); + } + if (!(basicAuthUser.equals(credential.username()) && basicAuthPassword.equals(credential.password()))) { throw new SignatureCheckException("Failed to authenticate via basic authentication"); } return new BasicPrincipal(credential.username()); } + + private static Map loadLocalUsers(Configurations conf) { + if (!conf.get(AmoroManagementConf.AUTHORIZATION_ENABLED)) { + return Collections.emptyMap(); + } + + List> users = + conf.getOptional(AmoroManagementConf.AUTHORIZATION_USERS).orElse(Collections.emptyList()); + return users.stream() + .filter(DefaultPasswdAuthenticationProvider::hasRequiredLocalAuthFields) + .collect( + Collectors.toMap( + user -> String.valueOf(user.get("username")), + user -> String.valueOf(user.get("password")), + (existing, replacement) -> { + LOG.warn( + "Duplicate authorization.users entry for password auth, keeping last user definition"); + return replacement; + })); + } + + private static boolean hasRequiredLocalAuthFields(Map user) { + if (user.get("username") == null || user.get("password") == null) { + LOG.warn("Ignore invalid authorization.users entry for password auth: {}", user); + return false; + } + return true; + } } diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/authentication/HttpAuthenticationFactory.java b/amoro-ams/src/main/java/org/apache/amoro/server/authentication/HttpAuthenticationFactory.java index 2699741c59..7a4ba1450b 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/authentication/HttpAuthenticationFactory.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/authentication/HttpAuthenticationFactory.java @@ -56,8 +56,15 @@ private static T createAuthenticationProvider( .impl(className) .buildChecked() .newInstance(conf); + } catch (NoSuchMethodException e) { + throw new IllegalStateException( + className + + " must implement " + + expected.getName() + + " and provide a public constructor (Configurations) or no-arg constructor", + e); } catch (Exception e) { - throw new IllegalStateException(className + " must extend of " + expected.getName()); + throw new IllegalStateException("Failed to create " + className + ": " + e.getMessage(), e); } } diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/authentication/LdapPasswdAuthenticationProvider.java b/amoro-ams/src/main/java/org/apache/amoro/server/authentication/LdapPasswdAuthenticationProvider.java index 4ce70d793b..3944717d75 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/authentication/LdapPasswdAuthenticationProvider.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/authentication/LdapPasswdAuthenticationProvider.java @@ -54,12 +54,14 @@ public LdapPasswdAuthenticationProvider(Configurations conf) { @Override public BasicPrincipal authenticate(PasswordCredential credential) throws SignatureCheckException { + String username = normalizeUsername(credential.username()); Hashtable env = new Hashtable(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); env.put(Context.PROVIDER_URL, ldapUrl); env.put(Context.SECURITY_AUTHENTICATION, "simple"); env.put(Context.SECURITY_CREDENTIALS, credential.password()); - env.put(Context.SECURITY_PRINCIPAL, formatter.format(new String[] {credential.username()})); + env.put(Context.SECURITY_PRINCIPAL, formatter.format(new String[] {username})); + env.put(Context.REFERRAL, "follow"); InitialDirContext initialLdapContext = null; try { @@ -75,6 +77,17 @@ public BasicPrincipal authenticate(PasswordCredential credential) throws Signatu } } } - return new BasicPrincipal(credential.username()); + return new BasicPrincipal(username); + } + + /** + * Strip email domain suffix if present so that "xuba@cisco.com" becomes "xuba". The LDAP + * user-pattern template expects a plain username, not an email address. + */ + private static String normalizeUsername(String username) { + if (username != null && username.contains("@")) { + return username.substring(0, username.indexOf('@')); + } + return username; } } diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/authorization/LdapGroupRoleResolver.java b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/LdapGroupRoleResolver.java new file mode 100644 index 0000000000..14fb3dca7d --- /dev/null +++ b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/LdapGroupRoleResolver.java @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.amoro.server.authorization; + +import org.apache.amoro.config.Configurations; +import org.apache.amoro.server.AmoroManagementConf; +import org.apache.amoro.server.utils.PreconditionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.naming.Context; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.InitialDirContext; + +import java.text.MessageFormat; +import java.util.Collections; +import java.util.HashSet; +import java.util.Hashtable; +import java.util.Set; + +class LdapGroupRoleResolver { + private static final Logger LOG = LoggerFactory.getLogger(LdapGroupRoleResolver.class); + + interface GroupMemberLoader { + Set loadMembers(String groupDn, String memberAttribute) throws NamingException; + } + + private final boolean enabled; + private final Role defaultRole; + private final String adminGroupDn; + private final String memberAttribute; + private final MessageFormat userDnFormatter; + private final GroupMemberLoader groupMemberLoader; + + static LdapGroupRoleResolver disabled(Role defaultRole) { + return new LdapGroupRoleResolver(defaultRole); + } + + /** Production constructor that resolves LDAP group membership through JNDI. */ + LdapGroupRoleResolver(Configurations conf) { + this(conf, new JndiGroupMemberLoader(conf)); + } + + private LdapGroupRoleResolver(Role defaultRole) { + this.enabled = false; + this.defaultRole = defaultRole; + this.adminGroupDn = ""; + this.memberAttribute = ""; + this.userDnFormatter = new MessageFormat("{0}"); + this.groupMemberLoader = (groupDn, attribute) -> Collections.emptySet(); + } + + LdapGroupRoleResolver(Configurations conf, GroupMemberLoader groupMemberLoader) { + this.enabled = conf.get(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ENABLED); + this.defaultRole = conf.get(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE); + this.groupMemberLoader = groupMemberLoader; + + if (!enabled) { + this.adminGroupDn = ""; + this.memberAttribute = ""; + this.userDnFormatter = new MessageFormat("{0}"); + return; + } + + String ldapUrl = conf.get(AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_LDAP_URL); + PreconditionUtils.checkNotNullOrEmpty( + ldapUrl, AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_LDAP_URL.key()); + + this.adminGroupDn = + conf.get(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ADMIN_GROUP_DN); + PreconditionUtils.checkNotNullOrEmpty( + adminGroupDn, AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ADMIN_GROUP_DN.key()); + + this.memberAttribute = + conf.get(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_GROUP_MEMBER_ATTRIBUTE); + PreconditionUtils.checkNotNullOrEmpty( + memberAttribute, + AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_GROUP_MEMBER_ATTRIBUTE.key()); + + String userDnPattern = + conf.get(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_USER_DN_PATTERN); + PreconditionUtils.checkNotNullOrEmpty( + userDnPattern, AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_USER_DN_PATTERN.key()); + this.userDnFormatter = new MessageFormat(userDnPattern); + } + + Role resolve(String username) { + if (!enabled) { + return defaultRole; + } + + String userDn = userDnFormatter.format(new String[] {username}); + try { + Set members = groupMemberLoader.loadMembers(adminGroupDn, memberAttribute); + for (String member : members) { + if (matchesMember(username, userDn, member)) { + return Role.ADMIN; + } + } + } catch (NamingException e) { + LOG.error( + "Failed to query LDAP group {} for user {}. " + + "This is likely a configuration error (e.g. missing or wrong bind-password, " + + "incorrect admin-group-dn). Please check the authorization.ldap-role-mapping " + + "settings in config.yaml.", + adminGroupDn, + username, + e); + throw new RuntimeException( + "LDAP role resolution failed: unable to query group '" + + adminGroupDn + + "'. Please check the LDAP role-mapping configuration (bind-dn/bind-password/admin-group-dn).", + e); + } + return defaultRole; + } + + private static boolean matchesMember(String username, String userDn, String member) { + if (member == null) { + return false; + } + + String normalized = member.trim(); + // Support DN-style ("CN=alice,OU=..."), plain username, and uid= prefix formats. + return normalized.equalsIgnoreCase(userDn) + || normalized.equalsIgnoreCase(username) + || normalized.equalsIgnoreCase("uid=" + username) + || extractCnFromDn(normalized).equalsIgnoreCase(username); + } + + /** Extract the CN value from a DN string, e.g. "CN=xuba,OU=Employees,..." → "xuba". */ + private static String extractCnFromDn(String dn) { + if (dn.toUpperCase().startsWith("CN=")) { + int commaIdx = dn.indexOf(','); + return commaIdx > 0 ? dn.substring(3, commaIdx) : dn.substring(3); + } + return ""; + } + + private static class JndiGroupMemberLoader implements GroupMemberLoader { + private final String ldapUrl; + private final String bindDn; + private final String bindPassword; + + private JndiGroupMemberLoader(Configurations conf) { + this.ldapUrl = conf.get(AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_LDAP_URL); + this.bindDn = conf.get(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_BIND_DN); + this.bindPassword = + conf.get(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_BIND_PASSWORD); + } + + @Override + public Set loadMembers(String groupDn, String memberAttribute) throws NamingException { + Hashtable env = new Hashtable<>(); + env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); + env.put(Context.PROVIDER_URL, ldapUrl); + env.put("com.sun.jndi.ldap.connect.timeout", "10000"); + env.put("com.sun.jndi.ldap.read.timeout", "10000"); + env.put(Context.REFERRAL, "follow"); + if (!bindDn.isEmpty()) { + env.put(Context.SECURITY_AUTHENTICATION, "simple"); + env.put(Context.SECURITY_PRINCIPAL, bindDn); + env.put(Context.SECURITY_CREDENTIALS, bindPassword); + } + + InitialDirContext ldapContext = null; + try { + ldapContext = new InitialDirContext(env); + LOG.debug( + "Loading LDAP group members by full DN: groupDn={}, memberAttribute={}", + groupDn, + memberAttribute); + Attributes attributes = ldapContext.getAttributes(groupDn, new String[] {memberAttribute}); + return extractMembers(attributes, memberAttribute); + } finally { + if (ldapContext != null) { + try { + ldapContext.close(); + } catch (NamingException e) { + LOG.warn("Failed to close LDAP role-mapping context", e); + } + } + } + } + + /** Extract all member values from an Attributes object. */ + private static Set extractMembers(Attributes attributes, String memberAttribute) + throws NamingException { + Attribute memberValues = attributes.get(memberAttribute); + if (memberValues == null) { + return Collections.emptySet(); + } + + Set members = new HashSet<>(); + NamingEnumeration values = memberValues.getAll(); + while (values.hasMore()) { + Object value = values.next(); + if (value != null) { + members.add(value.toString()); + } + } + return members; + } + } +} diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/authorization/Role.java b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/Role.java new file mode 100644 index 0000000000..3d4c575c21 --- /dev/null +++ b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/Role.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.amoro.server.authorization; + +public enum Role { + ADMIN, + READ_ONLY; + + public boolean isAdmin() { + return this == ADMIN; + } + + public boolean canWrite() { + return isAdmin(); + } +} diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/authorization/RoleResolver.java b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/RoleResolver.java new file mode 100644 index 0000000000..5ae851b8e1 --- /dev/null +++ b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/RoleResolver.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.amoro.server.authorization; + +import org.apache.amoro.config.Configurations; +import org.apache.amoro.server.AmoroManagementConf; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class RoleResolver { + private static final Logger LOG = LoggerFactory.getLogger(RoleResolver.class); + + private final boolean authorizationEnabled; + private final Role defaultRole; + private final Map localUserRoles; + private final Set adminUsers; + private final String adminUsername; + private final LdapGroupRoleResolver ldapGroupRoleResolver; + + public RoleResolver(Configurations serviceConfig) { + this(serviceConfig, createLdapGroupRoleResolver(serviceConfig)); + } + + RoleResolver(Configurations serviceConfig, LdapGroupRoleResolver ldapGroupRoleResolver) { + this.authorizationEnabled = serviceConfig.get(AmoroManagementConf.AUTHORIZATION_ENABLED); + this.defaultRole = serviceConfig.get(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE); + this.localUserRoles = loadLocalUserRoles(serviceConfig); + this.adminUsers = + serviceConfig.getOptional(AmoroManagementConf.AUTHORIZATION_ADMIN_USERS) + .orElse(Collections.emptyList()).stream() + .map(String::trim) + .filter(user -> !user.isEmpty()) + .collect(Collectors.toSet()); + this.adminUsername = serviceConfig.get(AmoroManagementConf.ADMIN_USERNAME); + this.ldapGroupRoleResolver = ldapGroupRoleResolver; + } + + private static LdapGroupRoleResolver createLdapGroupRoleResolver(Configurations serviceConfig) { + boolean authorizationEnabled = serviceConfig.get(AmoroManagementConf.AUTHORIZATION_ENABLED); + boolean ldapRoleMappingEnabled = + serviceConfig.get(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ENABLED); + if (!authorizationEnabled && ldapRoleMappingEnabled) { + LOG.warn( + "Ignore http-server.authorization.ldap-role-mapping configuration because http-server.authorization.enabled is false"); + } + + return authorizationEnabled + ? new LdapGroupRoleResolver(serviceConfig) + : LdapGroupRoleResolver.disabled( + serviceConfig.get(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE)); + } + + public boolean isAuthorizationEnabled() { + return authorizationEnabled; + } + + public Role resolve(String username) { + if (!authorizationEnabled) { + return Role.ADMIN; + } + + Role localRole = localUserRoles.get(username); + if (localRole != null) { + return localRole; + } + + if (adminUsers.contains(username) || adminUsername.equals(username)) { + return Role.ADMIN; + } + + return ldapGroupRoleResolver.resolve(username); + } + + private static Map loadLocalUserRoles(Configurations serviceConfig) { + List> users = + serviceConfig + .getOptional(AmoroManagementConf.AUTHORIZATION_USERS) + .orElse(Collections.emptyList()); + return users.stream() + .filter(RoleResolver::hasRequiredRoleFields) + .collect( + Collectors.toMap( + user -> String.valueOf(user.get("username")), + user -> + parseRole( + String.valueOf(user.get("username")), String.valueOf(user.get("role"))), + (existing, replacement) -> { + LOG.warn( + "Duplicate authorization.users entry for role resolution, keeping last user definition"); + return replacement; + })); + } + + private static boolean hasRequiredRoleFields(Map user) { + if (user.get("username") == null || user.get("role") == null) { + LOG.warn("Ignore invalid authorization.users entry for role resolution: {}", user); + return false; + } + return true; + } + + private static Role parseRole(String username, String roleValue) { + try { + return Role.valueOf(roleValue.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + String.format( + "Invalid role '%s' configured for authorization user '%s'", roleValue, username), + e); + } + } +} diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java index e465e65e76..ba3747ceb3 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java @@ -39,6 +39,7 @@ import org.apache.amoro.server.AmoroServiceContainer; import org.apache.amoro.server.RestCatalogService; import org.apache.amoro.server.authentication.HttpAuthenticationFactory; +import org.apache.amoro.server.authorization.RoleResolver; import org.apache.amoro.server.catalog.CatalogManager; import org.apache.amoro.server.dashboard.controller.ApiTokenController; import org.apache.amoro.server.dashboard.controller.CatalogController; @@ -68,6 +69,7 @@ import java.nio.charset.StandardCharsets; import java.security.Principal; import java.util.Objects; +import java.util.Set; import java.util.function.Consumer; public class DashboardServer { @@ -78,6 +80,13 @@ public class DashboardServer { private static final String AUTH_TYPE_JWT = "jwt"; private static final String X_REQUEST_SOURCE_HEADER = "X-Request-Source"; private static final String X_REQUEST_SOURCE_WEB = "Web"; + private static final String LOGIN_REQUIRED_MESSAGE = "Please login first"; + private static final String NO_PERMISSION_MESSAGE = "No permission"; + // Javalin 4.x Context#method returns an upper-case HTTP method string. + private static final Set WRITE_METHODS = Set.of("POST", "PUT", "DELETE"); + // URL white list is checked first; this set is only for authenticated write APIs that stay + // reachable for every logged-in role. + private static final Set WRITE_WHITELIST = Set.of("/api/ams/v1/logout"); private final CatalogController catalogController; private final HealthCheckController healthCheckController; private final LoginController loginController; @@ -94,6 +103,7 @@ public class DashboardServer { private final PasswdAuthenticationProvider basicAuthProvider; private final TokenAuthenticationProvider jwtAuthProvider; private final String proxyClientIpHeader; + private final RoleResolver roleResolver; public DashboardServer( Configurations serviceConfig, @@ -105,7 +115,8 @@ public DashboardServer( PlatformFileManager platformFileManager = new PlatformFileManager(); this.catalogController = new CatalogController(catalogManager, platformFileManager); this.healthCheckController = new HealthCheckController(ams); - this.loginController = new LoginController(serviceConfig); + this.roleResolver = new RoleResolver(serviceConfig); + this.loginController = new LoginController(serviceConfig, roleResolver); this.optimizerGroupController = new OptimizerGroupController(tableManager, optimizerManager); this.optimizerController = new OptimizerController(optimizerManager); this.platformFileInfoController = new PlatformFileInfoController(platformFileManager); @@ -408,8 +419,19 @@ public void preHandleRequest(Context ctx) { boolean isWebRequest = X_REQUEST_SOURCE_WEB.equalsIgnoreCase(requestSource); if (isWebRequest) { - if (null == ctx.sessionAttribute("user")) { - throw new ForbiddenException("User session attribute is missed for url: " + uriPath); + LoginController.SessionInfo user = ctx.sessionAttribute("user"); + if (user == null) { + throw new ForbiddenException(LOGIN_REQUIRED_MESSAGE); + } + if (shouldCheckWritePermission(ctx) + && !isWriteWhitelisted(uriPath) + && !user.getRole().canWrite()) { + LOG.warn( + "Reject write request for read-only user {}, URI: {}, method: {}", + user.getUserName(), + uriPath, + ctx.method()); + throw new ForbiddenException(NO_PERMISSION_MESSAGE); } return; } @@ -439,7 +461,7 @@ public void handleException(Exception e, Context ctx) { if (!ctx.req.getRequestURI().startsWith("/api/ams")) { ctx.html(getIndexFileContent()); } else { - ctx.json(new ErrorResponse(HttpCode.FORBIDDEN, "Please login first", "")); + ctx.json(new ErrorResponse(HttpCode.FORBIDDEN, e.getMessage(), "")); } } else if (e instanceof SignatureCheckException) { ctx.json(new ErrorResponse(HttpCode.FORBIDDEN, "Signature check failed", "")); @@ -488,4 +510,12 @@ private static boolean inWhiteList(String uri) { } return false; } + + private boolean shouldCheckWritePermission(Context ctx) { + return roleResolver.isAuthorizationEnabled() && WRITE_METHODS.contains(ctx.method()); + } + + private static boolean isWriteWhitelisted(String uriPath) { + return WRITE_WHITELIST.contains(uriPath); + } } diff --git a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/LoginController.java b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/LoginController.java index 4d39705c08..1e27d42f80 100644 --- a/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/LoginController.java +++ b/amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/LoginController.java @@ -24,12 +24,15 @@ import org.apache.amoro.server.AmoroManagementConf; import org.apache.amoro.server.authentication.DefaultPasswordCredential; import org.apache.amoro.server.authentication.HttpAuthenticationFactory; +import org.apache.amoro.server.authorization.Role; +import org.apache.amoro.server.authorization.RoleResolver; import org.apache.amoro.server.dashboard.response.OkResponse; import org.apache.amoro.server.utils.PreconditionUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.Serializable; +import java.security.Principal; import java.util.Map; /** The controller that handles login requests. */ @@ -37,11 +40,13 @@ public class LoginController { public static final Logger LOG = LoggerFactory.getLogger(LoginController.class); private final PasswdAuthenticationProvider loginAuthProvider; + private final RoleResolver roleResolver; - public LoginController(Configurations serviceConfig) { + public LoginController(Configurations serviceConfig, RoleResolver roleResolver) { this.loginAuthProvider = HttpAuthenticationFactory.getPasswordAuthenticationProvider( serviceConfig.get(AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_PROVIDER), serviceConfig); + this.roleResolver = roleResolver; } /** Get current user. */ @@ -59,15 +64,36 @@ public void login(Context ctx) { PreconditionUtils.checkNotNullOrEmpty(user, "user"); PreconditionUtils.checkNotNullOrEmpty(pwd, "password"); DefaultPasswordCredential credential = new DefaultPasswordCredential(user, pwd); + + // Step 1: Authenticate the user + Principal principal; try { - this.loginAuthProvider.authenticate(credential); - ctx.sessionAttribute("user", new SessionInfo(user, System.currentTimeMillis() + "")); - ctx.json(OkResponse.of("success")); + principal = this.loginAuthProvider.authenticate(credential); } catch (Exception e) { LOG.error("authenticate user {} failed", user, e); String causeMessage = e.getMessage() != null ? e.getMessage() : "unknown error"; throw new RuntimeException("invalid user " + user + " or password! Cause: " + causeMessage); } + + // Step 2: Resolve user role (LDAP group lookup) + String authenticatedUser = principal.getName(); + Role role; + try { + role = roleResolver.resolve(authenticatedUser); + } catch (Exception e) { + LOG.error( + "Role resolution failed for user {}. " + + "Authentication succeeded but LDAP group query failed. " + + "Check authorization.ldap-role-mapping config (bind-dn/bind-password/admin-group-dn).", + authenticatedUser, + e); + throw new RuntimeException("Login failed due to a server error during role resolution"); + } + + SessionInfo sessionInfo = + new SessionInfo(authenticatedUser, System.currentTimeMillis() + "", role); + ctx.sessionAttribute("user", sessionInfo); + ctx.json(OkResponse.of(sessionInfo)); } /** handle logout post request. */ @@ -76,29 +102,28 @@ public void logout(Context ctx) { ctx.json(OkResponse.ok()); } - static class SessionInfo implements Serializable { + /** Session user payload persisted in the server-side HTTP session. */ + public static class SessionInfo implements Serializable { String userName; String loginTime; + Role role; - public SessionInfo(String username, String loginTime) { + public SessionInfo(String username, String loginTime, Role role) { this.userName = username; this.loginTime = loginTime; + this.role = role; } public String getUserName() { return userName; } - public void setUserName(String userName) { - this.userName = userName; - } - public String getLoginTime() { return loginTime; } - public void setLoginTime(String loginTime) { - this.loginTime = loginTime; + public Role getRole() { + return role; } } } diff --git a/amoro-ams/src/test/java/org/apache/amoro/config/ConfigurationsTest.java b/amoro-ams/src/test/java/org/apache/amoro/config/ConfigurationsTest.java index 1e9a9df41a..2a8166c962 100644 --- a/amoro-ams/src/test/java/org/apache/amoro/config/ConfigurationsTest.java +++ b/amoro-ams/src/test/java/org/apache/amoro/config/ConfigurationsTest.java @@ -32,6 +32,8 @@ import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -59,6 +61,50 @@ public class ConfigurationsTest { public static String UPDATE_CMD = "UPDATE=1 ./mvnw test -pl amoro-ams -am -Dtest=ConfigurationsTest"; + private static final List RBAC_EXAMPLE = + Arrays.asList( + "## RBAC Example", + "", + "Enable RBAC only when you need role separation for dashboard users." + + " When `http-server.authorization.enabled`", + "is `false`, all authenticated dashboard users keep admin behavior for compatibility.", + "", + "```yaml", + "ams:", + " http-server:", + " authorization:", + " enabled: true", + " default-role: READ_ONLY", + " admin-users:", + " - alice", + " - bob", + " users:", + " - username: admin", + " password: admin", + " role: ADMIN", + " - username: viewer", + " password: viewer123", + " role: READ_ONLY", + "```", + "", + "```yaml", + "ams:", + " http-server:", + " login-auth-provider: org.apache.amoro.server.authentication.LdapPasswdAuthenticationProvider", + " login-auth-ldap-url: \"ldap://ldap.example.com:389\"", + " login-auth-ldap-user-pattern: \"uid={0},ou=people,dc=example,dc=com\"", + " authorization:", + " enabled: true", + " default-role: READ_ONLY", + " ldap-role-mapping:", + " enabled: true", + " admin-group-dn: \"cn=amoro-admins,ou=groups,dc=example,dc=com\"", + " group-member-attribute: \"member\"", + " user-dn-pattern: \"uid={0},ou=people,dc=example,dc=com\"", + " bind-dn: \"cn=service-account,dc=example,dc=com\"", + " bind-password: \"service-password\"", + "```"); + @Test public void testAmoroManagementConfDocumentation() throws Exception { List confInfoList = new ArrayList<>(); @@ -66,7 +112,8 @@ public void testAmoroManagementConfDocumentation() throws Exception { new AmoroConfInfo( AmoroManagementConf.class, "Amoro Management Service Configuration", - "The configuration options for Amoro Management Service (AMS).")); + "The configuration options for Amoro Management Service (AMS).", + RBAC_EXAMPLE)); confInfoList.add( new AmoroConfInfo( ConfigShadeUtils.class, @@ -147,6 +194,12 @@ protected void generateConfigurationMarkdown( // Add some space between different configuration sections output.add(""); + + // Add appendix content if present + if (confInfo.appendix != null && !confInfo.appendix.isEmpty()) { + output.addAll(confInfo.appendix); + } + output.add(""); } @@ -299,11 +352,21 @@ public static class AmoroConfInfo { Class confClass; String title; String description; + List appendix; public AmoroConfInfo(Class confClass, String title, String description) { this.confClass = confClass; this.title = title; this.description = description; + this.appendix = Collections.emptyList(); + } + + public AmoroConfInfo( + Class confClass, String title, String description, List appendix) { + this.confClass = confClass; + this.title = title; + this.description = description; + this.appendix = appendix; } } } diff --git a/amoro-ams/src/test/java/org/apache/amoro/server/authentication/HttpAuthenticationFactoryTest.java b/amoro-ams/src/test/java/org/apache/amoro/server/authentication/HttpAuthenticationFactoryTest.java index a8967a8dc7..25ae09dba7 100644 --- a/amoro-ams/src/test/java/org/apache/amoro/server/authentication/HttpAuthenticationFactoryTest.java +++ b/amoro-ams/src/test/java/org/apache/amoro/server/authentication/HttpAuthenticationFactoryTest.java @@ -28,7 +28,10 @@ import org.apache.amoro.server.AmoroManagementConf; import org.junit.jupiter.api.Test; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; public class HttpAuthenticationFactoryTest { @Test @@ -67,6 +70,74 @@ public void testPasswordAuthenticationProvider() { }); } + @Test + public void testPasswordAuthenticationProviderWithLocalUsers() throws Exception { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.ADMIN_USERNAME, "admin"); + conf.set(AmoroManagementConf.ADMIN_PASSWORD, "password"); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set( + AmoroManagementConf.AUTHORIZATION_USERS, + Arrays.asList(localUser("viewer", "viewer123", "READ_ONLY"))); + + PasswdAuthenticationProvider passwdAuthenticationProvider = + HttpAuthenticationFactory.getPasswordAuthenticationProvider( + DefaultPasswdAuthenticationProvider.class.getName(), conf); + + assert passwdAuthenticationProvider + .authenticate(new DefaultPasswordCredential("viewer", "viewer123")) + .getName() + .equals("viewer"); + } + + @Test + public void testPasswordAuthenticationProviderIgnoresLocalUsersWhenAuthorizationDisabled() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.ADMIN_USERNAME, "admin"); + conf.set(AmoroManagementConf.ADMIN_PASSWORD, "password"); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, false); + conf.set( + AmoroManagementConf.AUTHORIZATION_USERS, + Arrays.asList(localUser("viewer", "viewer123", "READ_ONLY"))); + + PasswdAuthenticationProvider passwdAuthenticationProvider = + HttpAuthenticationFactory.getPasswordAuthenticationProvider( + DefaultPasswdAuthenticationProvider.class.getName(), conf); + + assertThrows( + SignatureCheckException.class, + () -> + passwdAuthenticationProvider.authenticate( + new DefaultPasswordCredential("viewer", "viewer123"))); + } + + @Test + public void testPasswordAuthenticationProviderKeepsLastDuplicateLocalUser() throws Exception { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.ADMIN_USERNAME, "admin"); + conf.set(AmoroManagementConf.ADMIN_PASSWORD, "password"); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set( + AmoroManagementConf.AUTHORIZATION_USERS, + Arrays.asList( + localUser("viewer", "viewer123", "READ_ONLY"), + localUser("viewer", "viewer456", "READ_ONLY"))); + + PasswdAuthenticationProvider passwdAuthenticationProvider = + HttpAuthenticationFactory.getPasswordAuthenticationProvider( + DefaultPasswdAuthenticationProvider.class.getName(), conf); + + assertThrows( + SignatureCheckException.class, + () -> + passwdAuthenticationProvider.authenticate( + new DefaultPasswordCredential("viewer", "viewer123"))); + assert passwdAuthenticationProvider + .authenticate(new DefaultPasswordCredential("viewer", "viewer456")) + .getName() + .equals("viewer"); + } + @Test public void testBearerTokenAuthenticationProvider() { Configurations conf = new Configurations(); @@ -96,4 +167,12 @@ public void testBearerTokenAuthenticationProvider() { Collections.singletonMap(TokenCredential.CLIENT_IP_KEY, "localhost"))); }); } + + private static Map localUser(String username, String password, String role) { + Map user = new HashMap<>(); + user.put("username", username); + user.put("password", password); + user.put("role", role); + return user; + } } diff --git a/amoro-ams/src/test/java/org/apache/amoro/server/authorization/LdapGroupRoleResolverTest.java b/amoro-ams/src/test/java/org/apache/amoro/server/authorization/LdapGroupRoleResolverTest.java new file mode 100644 index 0000000000..25c24e00eb --- /dev/null +++ b/amoro-ams/src/test/java/org/apache/amoro/server/authorization/LdapGroupRoleResolverTest.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.amoro.server.authorization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.apache.amoro.config.Configurations; +import org.apache.amoro.server.AmoroManagementConf; +import org.junit.jupiter.api.Test; + +import javax.naming.NamingException; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public class LdapGroupRoleResolverTest { + + @Test + public void testResolveAdminFromUserDnMember() { + LdapGroupRoleResolver resolver = + new LdapGroupRoleResolver( + baseConfig(), + (groupDn, memberAttribute) -> + Collections.singleton("uid=alice,ou=people,dc=example,dc=com")); + + assertEquals(Role.ADMIN, resolver.resolve("alice")); + } + + @Test + public void testResolveAdminFromUsernameMember() { + LdapGroupRoleResolver resolver = + new LdapGroupRoleResolver( + baseConfig(), (groupDn, memberAttribute) -> Collections.singleton("alice")); + + assertEquals(Role.ADMIN, resolver.resolve("alice")); + } + + @Test + public void testResolveAdminFromCnDnMember() { + // Cisco AD stores members as full CNs: "CN=xuba,OU=Employees,OU=Cisco Users,DC=cisco,DC=com" + LdapGroupRoleResolver resolver = + new LdapGroupRoleResolver( + baseConfig(), + (groupDn, memberAttribute) -> + Collections.singleton("CN=alice,OU=Employees,OU=Cisco Users,DC=cisco,DC=com")); + + assertEquals(Role.ADMIN, resolver.resolve("alice")); + } + + @Test + public void testResolveAdminFromMemberUidStyleValue() { + LdapGroupRoleResolver resolver = + new LdapGroupRoleResolver( + baseConfig(), (groupDn, memberAttribute) -> Collections.singleton("uid=alice")); + + assertEquals(Role.ADMIN, resolver.resolve("alice")); + } + + @Test + public void testResolveAdminIsCaseInsensitiveForDnMembers() { + LdapGroupRoleResolver resolver = + new LdapGroupRoleResolver( + baseConfig(), + (groupDn, memberAttribute) -> + Collections.singleton("UID=Alice,OU=People,DC=Example,DC=Com")); + + assertEquals(Role.ADMIN, resolver.resolve("alice")); + } + + @Test + public void testThrowsWhenLookupFails() { + LdapGroupRoleResolver resolver = + new LdapGroupRoleResolver( + baseConfig(), + (groupDn, memberAttribute) -> { + throw new NamingException("ldap unavailable"); + }); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> resolver.resolve("alice")); + assertTrue(ex.getMessage().contains("LDAP role resolution failed")); + } + + @Test + public void testRequireLdapGroupConfigWhenEnabled() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.READ_ONLY); + conf.set(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ENABLED, true); + conf.set(AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_LDAP_URL, "ldap://ldap.example.com:389"); + + assertThrows(IllegalArgumentException.class, () -> new LdapGroupRoleResolver(conf)); + } + + @Test + public void testRoleResolverFallsBackToLdapGroupMembership() { + Configurations conf = baseConfig(); + RoleResolver resolver = + new RoleResolver( + conf, + new LdapGroupRoleResolver( + conf, + (groupDn, memberAttribute) -> { + Set members = new HashSet<>(); + members.add("uid=alice,ou=people,dc=example,dc=com"); + return members; + })); + + assertEquals(Role.ADMIN, resolver.resolve("alice")); + assertEquals(Role.READ_ONLY, resolver.resolve("charlie")); + } + + @Test + public void testResolveNonAdminUserFallsBackToDefaultRole() { + LdapGroupRoleResolver resolver = + new LdapGroupRoleResolver( + baseConfig(), (groupDn, memberAttribute) -> Collections.singleton("bob")); + + assertEquals(Role.READ_ONLY, resolver.resolve("alice")); + } + + private static Configurations baseConfig() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.READ_ONLY); + conf.set(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ENABLED, true); + conf.set(AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_LDAP_URL, "ldap://ldap.example.com:389"); + conf.set( + AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ADMIN_GROUP_DN, + "cn=amoro-admins,ou=groups,dc=example,dc=com"); + conf.set( + AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_USER_DN_PATTERN, + "uid={0},ou=people,dc=example,dc=com"); + return conf; + } +} diff --git a/amoro-ams/src/test/java/org/apache/amoro/server/authorization/RoleResolverTest.java b/amoro-ams/src/test/java/org/apache/amoro/server/authorization/RoleResolverTest.java new file mode 100644 index 0000000000..f07e0bc105 --- /dev/null +++ b/amoro-ams/src/test/java/org/apache/amoro/server/authorization/RoleResolverTest.java @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.amoro.server.authorization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.apache.amoro.config.Configurations; +import org.apache.amoro.server.AmoroManagementConf; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class RoleResolverTest { + + @Test + public void testDisabledAuthorizationDefaultsToAdmin() { + Configurations conf = new Configurations(); + RoleResolver resolver = new RoleResolver(conf); + assertEquals(Role.ADMIN, resolver.resolve("viewer")); + } + + @Test + public void testResolveConfiguredUserAndFallbackRoles() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.ADMIN_USERNAME, "admin"); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.READ_ONLY); + conf.set(AmoroManagementConf.AUTHORIZATION_ADMIN_USERS, Arrays.asList("ldap_admin", " ")); + conf.set( + AmoroManagementConf.AUTHORIZATION_USERS, + Arrays.asList( + localUser("operator", "secret", "ADMIN"), localUser("viewer", "v", "READ_ONLY"))); + + RoleResolver resolver = new RoleResolver(conf); + + assertEquals(Role.ADMIN, resolver.resolve("operator")); + assertEquals(Role.READ_ONLY, resolver.resolve("viewer")); + assertEquals(Role.ADMIN, resolver.resolve("ldap_admin")); + assertEquals(Role.ADMIN, resolver.resolve("admin")); + assertEquals(Role.READ_ONLY, resolver.resolve("ldap_viewer")); + assertEquals(Role.READ_ONLY, resolver.resolve(" ")); + } + + @Test + public void testResolveLdapUserFromAdminWhitelist() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.ADMIN_USERNAME, "local_admin"); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.READ_ONLY); + conf.set(AmoroManagementConf.AUTHORIZATION_ADMIN_USERS, Arrays.asList("alice", "bob")); + + RoleResolver resolver = new RoleResolver(conf); + + assertEquals(Role.ADMIN, resolver.resolve("alice")); + assertEquals(Role.READ_ONLY, resolver.resolve("charlie")); + } + + @Test + public void testLocalUserRoleTakesPriorityOverLdapGroupRoleMapping() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.READ_ONLY); + conf.set(AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_LDAP_URL, "ldap://ldap.example.com:389"); + conf.set(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ENABLED, true); + conf.set( + AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ADMIN_GROUP_DN, + "cn=amoro-admins,ou=groups,dc=example,dc=com"); + conf.set( + AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_USER_DN_PATTERN, + "uid={0},ou=people,dc=example,dc=com"); + conf.set( + AmoroManagementConf.AUTHORIZATION_USERS, + Arrays.asList(localUser("alice", "viewer123", "READ_ONLY"))); + + RoleResolver resolver = + new RoleResolver( + conf, + new LdapGroupRoleResolver( + conf, + (groupDn, memberAttribute) -> + Collections.singleton("uid=alice,ou=people,dc=example,dc=com"))); + + assertEquals(Role.READ_ONLY, resolver.resolve("alice")); + } + + @Test + public void testDisabledAuthorizationIgnoresEnabledLdapRoleMapping() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, false); + conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.READ_ONLY); + conf.set(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ENABLED, true); + + RoleResolver resolver = new RoleResolver(conf); + + assertEquals(Role.ADMIN, resolver.resolve("alice")); + } + + @Test + public void testInvalidConfiguredRoleFailsWithHelpfulMessage() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set( + AmoroManagementConf.AUTHORIZATION_USERS, + Arrays.asList(localUser("viewer", "viewer123", "writer"))); + + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new RoleResolver(conf)); + + assertEquals( + "Invalid role 'writer' configured for authorization user 'viewer'", exception.getMessage()); + } + + @Test + public void testDuplicateConfiguredUsersKeepLastDefinition() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set( + AmoroManagementConf.AUTHORIZATION_USERS, + Arrays.asList( + localUser("viewer", "viewer123", "READ_ONLY"), + localUser("viewer", "viewer123", "ADMIN"))); + + RoleResolver resolver = new RoleResolver(conf); + + assertEquals(Role.ADMIN, resolver.resolve("viewer")); + } + + @Test + public void testEnabledAuthorizationWithoutExplicitUsersFallsBackToDefaultRole() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.ADMIN_USERNAME, "admin"); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.READ_ONLY); + + RoleResolver resolver = new RoleResolver(conf); + + assertEquals(Role.READ_ONLY, resolver.resolve("viewer")); + } + + @Test + public void testInvalidUserEntriesAreIgnored() { + Configurations conf = new Configurations(); + conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true); + conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.READ_ONLY); + conf.set( + AmoroManagementConf.AUTHORIZATION_USERS, + Arrays.asList(invalidUserWithMissingUsername("viewer123", "ADMIN"))); + + RoleResolver resolver = new RoleResolver(conf); + + assertEquals(Role.READ_ONLY, resolver.resolve("viewer")); + } + + private static Map localUser(String username, String password, String role) { + Map user = new HashMap<>(); + user.put("username", username); + user.put("password", password); + user.put("role", role); + return user; + } + + private static Map invalidUserWithMissingUsername(String password, String role) { + Map user = new HashMap<>(); + user.put("password", password); + user.put("role", role); + return user; + } +} diff --git a/amoro-web/mock/modules/common.js b/amoro-web/mock/modules/common.js index 089dea2448..b97ccbc28c 100644 --- a/amoro-web/mock/modules/common.js +++ b/amoro-web/mock/modules/common.js @@ -25,7 +25,8 @@ export default [ msg: 'success', "result": { "userName": "admin", - "loginTime": "1703839452053" + "loginTime": "1703839452053", + "role": "ADMIN" } }), }, @@ -35,7 +36,11 @@ export default [ response: () => ({ code: 200, msg: 'success', - result: 'success' + result: { + userName: 'admin', + loginTime: '1703839452053', + role: 'ADMIN' + } }), }, { diff --git a/amoro-web/src/components/Sidebar.vue b/amoro-web/src/components/Sidebar.vue index 31cf72beea..562f0fcd3b 100644 --- a/amoro-web/src/components/Sidebar.vue +++ b/amoro-web/src/components/Sidebar.vue @@ -22,6 +22,7 @@ import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import useStore from '@/store/index' import { getQueryString } from '@/utils' +import { canWrite } from '@/utils/permission' interface MenuItem { key: string @@ -44,6 +45,7 @@ export default defineComponent({ const hasToken = computed(() => { return !!(getQueryString('token') || '') }) + const writable = computed(() => canWrite()) const menuList = computed(() => { const menu: MenuItem[] = [ { @@ -84,7 +86,8 @@ export default defineComponent({ icon: 'settings', }, ] - return hasToken.value ? menu : allMenu + const source = hasToken.value ? menu : allMenu + return source.filter(item => writable.value || item.key !== 'settings') }) const setCurMenu = () => { @@ -164,6 +167,7 @@ export default defineComponent({ return { ...toRefs(state), hasToken, + writable, menuList, toggleCollapsed, navClick, diff --git a/amoro-web/src/components/Topbar.vue b/amoro-web/src/components/Topbar.vue index 3e24f1a66b..1b7b14d00e 100644 --- a/amoro-web/src/components/Topbar.vue +++ b/amoro-web/src/components/Topbar.vue @@ -40,6 +40,9 @@ const verInfo = reactive({ const { t, locale } = useI18n() const router = useRouter() const store = useStore() +function roleText() { + return store.userInfo.role === 'READ_ONLY' ? 'ReadOnly' : 'Admin' +} async function getVersion() { const res = await getVersionInfo() @@ -97,7 +100,7 @@ onMounted(() => { {{ `${$t('commitTime')}: ${verInfo.commitTime}` }} - {{ store.userInfo.userName }} + {{ roleText() }}{{ store.userInfo.userName }} diff --git a/amoro-web/src/views/resource/index.vue b/amoro-web/src/views/resource/index.vue index a3fd96545c..234d786c43 100644 --- a/amoro-web/src/views/resource/index.vue +++ b/amoro-web/src/views/resource/index.vue @@ -35,6 +35,7 @@ import type { IIOptimizeGroupItem, ILableAndValue } from '@/types/common.type' import GroupModal from '@/views/resource/components/GroupModal.vue' import CreateOptimizerModal from '@/views/resource/components/CreateOptimizerModal.vue' import { usePageScroll } from '@/hooks/usePageScroll' +import { canWrite } from '@/utils/permission' export default defineComponent({ name: 'Resource', @@ -49,6 +50,7 @@ export default defineComponent({ const router = useRouter() const route = useRoute() const { pageScrollRef } = usePageScroll() + const writable = canWrite() const tabConfig: ILableAndValue[] = shallowReactive([ { label: t('optimizerGroups'), value: 'optimizerGroups' }, { label: t('optimizers'), value: 'optimizers' }, @@ -131,6 +133,7 @@ export default defineComponent({ createOptimizer, t, pageScrollRef, + writable, } }, }) @@ -158,7 +161,7 @@ export default defineComponent({ :tab="t('optimizers')" :class="[activeTab === 'optimizers' ? 'active' : '']" > - + {{ t("createOptimizer") }} @@ -168,7 +171,7 @@ export default defineComponent({ :tab="t('optimizerGroups')" :class="[activeTab === 'optimizerGroups' ? 'active' : '']" > - + {{ t("addGroup") }} (false) @@ -77,6 +78,7 @@ const breadcrumbDataSource = reactive([]) const loading = ref(false) const cancelDisabled = ref(true) +const writable = ref(canWrite()) const pagination = reactive(usePagination()) const breadcrumbPagination = reactive(usePagination()) const route = useRoute() @@ -328,7 +330,7 @@ onMounted(() => { - diff --git a/amoro-web/src/views/terminal/index.vue b/amoro-web/src/views/terminal/index.vue index 4e696c56ab..ebc958bac3 100644 --- a/amoro-web/src/views/terminal/index.vue +++ b/amoro-web/src/views/terminal/index.vue @@ -28,6 +28,7 @@ import { executeSql, getExampleSqlCode, getJobDebugResult, getLastDebugInfo, get import { getCatalogList } from '@/services/table.service' import { usePlaceholder } from '@/hooks/usePlaceholder' import { usePageScroll } from '@/hooks/usePageScroll' +import { canWrite } from '@/utils/permission' interface ISessionInfo { sessionId: string @@ -53,6 +54,7 @@ export default defineComponent({ const sqlLogRef = ref(null) const { pageScrollRef } = usePageScroll() const readOnly = ref(false) + const writable = ref(canWrite()) const sqlSource = ref('') const showDebug = ref(false) const runStatus = ref('') @@ -130,6 +132,10 @@ export default defineComponent({ } async function handleDebug() { + if (!writable.value) { + message.error('ReadOnly user cannot execute SQL') + return + } try { if (!curCatalog.value) { message.error(placeholder.selectClPh) @@ -334,6 +340,7 @@ export default defineComponent({ handleFull, resultFull, showDebug, + writable, sqlSource, readOnly, generateCode, @@ -364,13 +371,13 @@ export default defineComponent({ @@ -411,7 +418,7 @@ export default defineComponent({ {{ code diff --git a/dist/src/main/amoro-bin/conf/config.yaml b/dist/src/main/amoro-bin/conf/config.yaml index 884ce5896b..9c9d44585e 100644 --- a/dist/src/main/amoro-bin/conf/config.yaml +++ b/dist/src/main/amoro-bin/conf/config.yaml @@ -40,6 +40,27 @@ ams: # login-auth-provider: org.apache.amoro.server.authentication.LdapPasswdAuthenticationProvider # login-auth-ldap-url: "ldap://ldap.example.com:389" # login-auth-ldap-user-pattern: "uid={0},ou=people,dc=example,dc=com" + # + # ── Authorization (RBAC) ── + # admin-users whitelist example: + # authorization: + # enabled: true + # default-role: READ_ONLY + # admin-users: + # - alice + # - bob + # + # LDAP Group role-mapping example: + # authorization: + # enabled: true + # default-role: READ_ONLY + # ldap-role-mapping: + # enabled: true + # admin-group-dn: "cn=amoro-admins,ou=groups,dc=example,dc=com" + # group-member-attribute: "member" + # user-dn-pattern: "uid={0},ou=people,dc=example,dc=com" + # bind-dn: "cn=service-account,dc=example,dc=com" + # bind-password: "service-password" refresh-external-catalogs: interval: 3min # 180000 @@ -111,7 +132,7 @@ ams: # Support for encrypted sensitive configuration items shade: identifier: default # Built-in support for default/base64. Defaults to "default", indicating no encryption - sensitive-keywords: admin-password;database.password + sensitive-keywords: admin-password;database.password;http-server.authorization.ldap-role-mapping.bind-password overview-cache: refresh-interval: 3min # 3 min diff --git a/docs/configuration/ams-config.md b/docs/configuration/ams-config.md index 0f5f272da9..a5b9e4d038 100644 --- a/docs/configuration/ams-config.md +++ b/docs/configuration/ams-config.md @@ -83,6 +83,16 @@ table td:last-child, table th:last-child { width: 40%; word-break: break-all; } | ha.zookeeper-auth-type | NONE | The Zookeeper authentication type, NONE or KERBEROS. | | http-server.auth-basic-provider | org.apache.amoro.server.authentication.DefaultPasswdAuthenticationProvider | User-defined password authentication implementation of org.apache.amoro.authentication.PasswdAuthenticationProvider | | http-server.auth-jwt-provider | <undefined> | User-defined JWT (JSON Web Token) authentication implementation of org.apache.amoro.authentication.TokenAuthenticationProvider | +| http-server.authorization.admin-users | | Additional usernames that should always be treated as admin users. | +| http-server.authorization.default-role | READ_ONLY | Default role for authenticated users without an explicit role mapping. | +| http-server.authorization.enabled | false | Whether to enable dashboard RBAC authorization. | +| http-server.authorization.ldap-role-mapping.admin-group-dn | <undefined> | Full DN of the LDAP admin group, e.g. CN=amoro-admins,OU=Groups,DC=example,DC=com. | +| http-server.authorization.ldap-role-mapping.bind-dn | | Optional LDAP bind DN used when querying role-mapping groups. | +| http-server.authorization.ldap-role-mapping.bind-password | | Optional LDAP bind password used when querying role-mapping groups. | +| http-server.authorization.ldap-role-mapping.enabled | false | Whether to resolve dashboard roles from LDAP group membership. | +| http-server.authorization.ldap-role-mapping.group-member-attribute | member | LDAP group attribute that stores member references. | +| http-server.authorization.ldap-role-mapping.user-dn-pattern | <undefined> | LDAP user DN pattern used to match group members. Use {0} as the username placeholder. | +| http-server.authorization.users | <undefined> | Local dashboard users with username/password/role entries. | | http-server.bind-port | 19090 | Port that the Http server is bound to. | | http-server.login-auth-ldap-url | <undefined> | LDAP connection URL(s), value could be a SPACE separated list of URLs to multiple LDAP servers for resiliency. URLs are tried in the order specified until the connection is successful | | http-server.login-auth-ldap-user-pattern | <undefined> | LDAP user pattern for authentication. The pattern defines how to construct the user's distinguished name (DN) in the LDAP directory. Use {0} as a placeholder for the username. For example, 'cn={0},ou=people,dc=example,dc=com' will search for users in the specified organizational unit. | @@ -131,6 +141,46 @@ table td:last-child, table th:last-child { width: 40%; word-break: break-all; } | thrift-server.table-service.worker-thread-count | 20 | The number of worker threads for the Thrift server. | | use-master-slave-mode | false | This setting controls whether to enable the AMS horizontal scaling feature, which is currently under development and testing. | +## RBAC Example + +Enable RBAC only when you need role separation for dashboard users. When `http-server.authorization.enabled` +is `false`, all authenticated dashboard users keep admin behavior for compatibility. + +```yaml +ams: + http-server: + authorization: + enabled: true + default-role: READ_ONLY + admin-users: + - alice + - bob + users: + - username: admin + password: admin + role: ADMIN + - username: viewer + password: viewer123 + role: READ_ONLY +``` + +```yaml +ams: + http-server: + login-auth-provider: org.apache.amoro.server.authentication.LdapPasswdAuthenticationProvider + login-auth-ldap-url: "ldap://ldap.example.com:389" + login-auth-ldap-user-pattern: "uid={0},ou=people,dc=example,dc=com" + authorization: + enabled: true + default-role: READ_ONLY + ldap-role-mapping: + enabled: true + admin-group-dn: "cn=amoro-admins,ou=groups,dc=example,dc=com" + group-member-attribute: "member" + user-dn-pattern: "uid={0},ou=people,dc=example,dc=com" + bind-dn: "cn=service-account,dc=example,dc=com" + bind-password: "service-password" +``` ## Shade Utils Configuration