This is an automated email from the ASF dual-hosted git repository.
jinsongzhou pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git
The following commit(s) were added to refs/heads/master by this push:
new 6c037912f [AMORO-4117] Implement dashboard RBAC with jCasbin and LDAP
role mapping (#4118)
6c037912f is described below
commit 6c037912f7ada649f69afb643041a75eeeea8825
Author: Xu Bai <[email protected]>
AuthorDate: Thu Mar 26 16:49:58 2026 +0800
[AMORO-4117] Implement dashboard RBAC with jCasbin and LDAP role mapping
(#4118)
* [Feature] Implement role-based access control with ADMIN and READ_ONLY
roles
* Ensure username and password are converted to String in authentication
and role resolution
* Update RBAC documentation and configuration examples in ams-config.md and
ConfigurationsTest.java
* [Feature] Update role-based access control to use SERVICE_ADMIN and
VIEWER roles
* [Feature] Add RBAC configuration for SERVICE_ADMIN and VIEWER roles
* [Feature] Update RBAC model and policy files with licensing information
and new dependencies
* [Feature] Refactor RBAC implementation to use string-based roles and
enhance privilege mapping
* Update RBAC default role handling and documentation
* Update RBAC documentation to reflect changes in default role handling and
introduce new roles
* Fix comment in LdapPasswdAuthenticationProvider to reflect correct email
example for username normalization
* revert
---
.gitignore | 2 +
amoro-ams/pom.xml | 12 ++
.../apache/amoro/server/AmoroManagementConf.java | 54 +++++
.../authentication/HttpAuthenticationFactory.java | 9 +-
.../LdapPasswdAuthenticationProvider.java | 17 +-
.../server/authorization/AuthorizationRequest.java | 54 +++++
.../authorization/CasbinAuthorizationManager.java | 148 +++++++++++++
.../authorization/DashboardPrivilegeMapper.java | 159 ++++++++++++++
.../DashboardPrivilegeMappingRule.java | 50 +++++
.../authorization/LdapGroupRoleResolver.java | 239 +++++++++++++++++++++
.../amoro/server/authorization/Privilege.java | 31 +++
.../amoro/server/authorization/ResourceType.java | 28 +++
.../apache/amoro/server/authorization/Role.java | 27 +++
.../amoro/server/authorization/RoleResolver.java | 87 ++++++++
.../amoro/server/dashboard/DashboardServer.java | 41 +++-
.../dashboard/controller/LoginController.java | 74 +++++--
.../resources/authorization/amoro_rbac_model.conf | 28 +++
.../resources/authorization/amoro_rbac_policy.csv | 32 +++
.../resources/authorization/privilege_mapping.yaml | 97 +++++++++
.../apache/amoro/config/ConfigurationsTest.java | 109 +++++++++-
.../CasbinAuthorizationManagerTest.java | 47 ++++
.../authorization/LdapGroupRoleResolverTest.java | 158 ++++++++++++++
.../server/authorization/RoleResolverTest.java | 113 ++++++++++
amoro-web/mock/modules/common.js | 11 +-
amoro-web/src/components/Sidebar.vue | 46 +++-
amoro-web/src/components/Topbar.vue | 15 +-
amoro-web/src/main.ts | 66 ++++++
amoro-web/src/router/index.ts | 2 +-
amoro-web/src/store/index.ts | 2 +
amoro-web/src/types/common.type.ts | 3 +
amoro-web/src/utils/permission.ts | 106 +++++++++
amoro-web/src/utils/request.ts | 28 ++-
amoro-web/src/views/catalogs/Detail.vue | 6 +-
amoro-web/src/views/catalogs/index.vue | 6 +-
amoro-web/src/views/hive-details/index.vue | 5 +-
amoro-web/src/views/login/index.vue | 11 +
amoro-web/src/views/resource/components/List.vue | 6 +-
amoro-web/src/views/resource/index.vue | 7 +-
.../src/views/tables/components/Optimizing.vue | 4 +-
amoro-web/src/views/terminal/index.vue | 13 +-
dev/deps/dependencies-hadoop-2-spark-3.3 | 8 +-
dev/deps/dependencies-hadoop-3-spark-3.5 | 8 +-
dist/src/main/amoro-bin/conf/config.yaml | 32 ++-
docs/configuration/ams-config.md | 93 ++++++++
pom.xml | 1 +
45 files changed, 2041 insertions(+), 54 deletions(-)
diff --git a/.gitignore b/.gitignore
index c140957d0..58055849d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,6 +77,8 @@ amoro-web/node/
public
resources
!javadoc/resources
+!amoro-ams/src/main/resources/
+!amoro-ams/src/main/resources/**
!dist/src/main/amoro-bin/bin/
!dist/src/main/amoro-bin/conf/
diff --git a/amoro-ams/pom.xml b/amoro-ams/pom.xml
index 3f83f5e02..34f5591c7 100644
--- a/amoro-ams/pom.xml
+++ b/amoro-ams/pom.xml
@@ -218,6 +218,18 @@
<artifactId>mybatis</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.casbin</groupId>
+ <artifactId>jcasbin</artifactId>
+ <version>${jcasbin.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>commons-io</groupId>
+ <artifactId>commons-io</artifactId>
+ <version>2.20.0</version>
+ </dependency>
+
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
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 fd0e70cf2..3a31933bf 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
@@ -26,6 +26,7 @@ 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 +54,59 @@ public class AmoroManagementConf {
.defaultValue("admin")
.withDescription("The administrator password");
+ public static final ConfigOption<Boolean> AUTHORIZATION_ENABLED =
+ ConfigOptions.key("http-server.authorization.enabled")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription("Whether to enable dashboard RBAC authorization.");
+
+ public static final ConfigOption<String> AUTHORIZATION_DEFAULT_ROLE =
+ ConfigOptions.key("http-server.authorization.default-role")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Optional default dashboard role for authenticated users without
an LDAP role mapping.");
+
+ public static final ConfigOption<Boolean>
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<String>
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<String>
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<List<Map<String, String>>>
+ AUTHORIZATION_LDAP_ROLE_MAPPING_GROUPS =
+
ConfigOptions.key("http-server.authorization.ldap-role-mapping.groups")
+ .mapType()
+ .asList()
+ .noDefaultValue()
+ .withDescription(
+ "LDAP group-to-role mapping entries containing group-dn and
role fields.");
+
+ public static final ConfigOption<String>
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<String>
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<Boolean> USE_MASTER_SLAVE_MODE =
ConfigOptions.key("use-master-slave-mode")
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 2699741c5..7a4ba1450 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 @@ public class HttpAuthenticationFactory {
.impl(className)
.<T>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 4ce70d793..f95df8bc7 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 class LdapPasswdAuthenticationProvider implements
PasswdAuthenticationPro
@Override
public BasicPrincipal authenticate(PasswordCredential credential) throws
SignatureCheckException {
+ String username = normalizeUsername(credential.username());
Hashtable<String, Object> env = new Hashtable<String, Object>();
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 class LdapPasswdAuthenticationProvider implements
PasswdAuthenticationPro
}
}
}
- return new BasicPrincipal(credential.username());
+ return new BasicPrincipal(username);
+ }
+
+ /**
+ * Strip email domain suffix if present so that "[email protected]" becomes
"amoro". 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/AuthorizationRequest.java
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/AuthorizationRequest.java
new file mode 100644
index 000000000..bcb16a353
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/AuthorizationRequest.java
@@ -0,0 +1,54 @@
+/*
+ * 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 class AuthorizationRequest {
+ public static final String GLOBAL_RESOURCE_ID = "GLOBAL";
+
+ private final ResourceType resourceType;
+ private final String resourceId;
+ private final Privilege privilege;
+
+ private AuthorizationRequest(ResourceType resourceType, String resourceId,
Privilege privilege) {
+ this.resourceType = resourceType;
+ this.resourceId = resourceId;
+ this.privilege = privilege;
+ }
+
+ public static AuthorizationRequest of(ResourceType resourceType, Privilege
privilege) {
+ return new AuthorizationRequest(resourceType, GLOBAL_RESOURCE_ID,
privilege);
+ }
+
+ public static AuthorizationRequest of(
+ ResourceType resourceType, String resourceId, Privilege privilege) {
+ return new AuthorizationRequest(resourceType, resourceId, privilege);
+ }
+
+ public ResourceType getResourceType() {
+ return resourceType;
+ }
+
+ public String getResourceId() {
+ return resourceId;
+ }
+
+ public Privilege getPrivilege() {
+ return privilege;
+ }
+}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/authorization/CasbinAuthorizationManager.java
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/CasbinAuthorizationManager.java
new file mode 100644
index 000000000..70f01ee02
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/CasbinAuthorizationManager.java
@@ -0,0 +1,148 @@
+/*
+ * 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.casbin.jcasbin.main.Enforcer;
+import org.casbin.jcasbin.model.Model;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class CasbinAuthorizationManager {
+ private static final String MODEL_RESOURCE =
"authorization/amoro_rbac_model.conf";
+ private static final String POLICY_RESOURCE =
"authorization/amoro_rbac_policy.csv";
+
+ private final boolean authorizationEnabled;
+ private final Enforcer enforcer;
+ private final Map<String, Set<String>> rolePrivileges;
+
+ public CasbinAuthorizationManager(Configurations serviceConfig) {
+ this.authorizationEnabled =
serviceConfig.get(AmoroManagementConf.AUTHORIZATION_ENABLED);
+ this.enforcer = authorizationEnabled ? createEnforcer() : null;
+ this.rolePrivileges =
+ authorizationEnabled ? loadRolePrivileges(POLICY_RESOURCE) :
Collections.emptyMap();
+ }
+
+ public boolean isAuthorizationEnabled() {
+ return authorizationEnabled;
+ }
+
+ public boolean authorize(Set<String> roles, AuthorizationRequest request) {
+ if (!authorizationEnabled) {
+ return true;
+ }
+
+ if (roles == null || roles.isEmpty()) {
+ return false;
+ }
+
+ for (String role : roles) {
+ if (Boolean.TRUE.equals(
+ enforcer.enforce(
+ role,
+ request.getResourceType().name(),
+ request.getResourceId(),
+ request.getPrivilege().name()))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public Set<String> resolvePrivileges(Set<String> roles) {
+ if (!authorizationEnabled) {
+ return java.util.Arrays.stream(Privilege.values())
+ .map(Enum::name)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ if (roles == null || roles.isEmpty()) {
+ return Collections.emptySet();
+ }
+
+ LinkedHashSet<String> privileges = new LinkedHashSet<>();
+ for (String role : roles) {
+ privileges.addAll(rolePrivileges.getOrDefault(role,
Collections.emptySet()));
+ }
+ return Collections.unmodifiableSet(privileges);
+ }
+
+ private static Enforcer createEnforcer() {
+ Model model = new Model();
+ model.loadModelFromText(readResource(MODEL_RESOURCE));
+
+ Enforcer enforcer = new Enforcer(model);
+ for (List<String> policy : readPolicies(POLICY_RESOURCE)) {
+ if (!policy.isEmpty() && "p".equals(policy.get(0))) {
+ enforcer.addPolicy(policy.subList(1, policy.size()));
+ }
+ }
+ return enforcer;
+ }
+
+ private static List<List<String>> readPolicies(String resource) {
+ return readResource(resource)
+ .lines()
+ .map(String::trim)
+ .filter(line -> !line.isEmpty() && !line.startsWith("#"))
+ .map(
+ line ->
+ List.of(line.split("\\s*,\\s*")).stream()
+ .map(String::trim)
+ .collect(Collectors.toList()))
+ .collect(Collectors.toList());
+ }
+
+ private static Map<String, Set<String>> loadRolePrivileges(String resource) {
+ return readPolicies(resource).stream()
+ .filter(policy -> policy.size() >= 5 && "p".equals(policy.get(0)))
+ .filter(policy -> policy.size() < 6 ||
!"deny".equalsIgnoreCase(policy.get(5)))
+ .collect(
+ Collectors.groupingBy(
+ policy -> policy.get(1),
+ Collectors.mapping(
+ policy -> policy.get(4),
Collectors.toCollection(LinkedHashSet::new))));
+ }
+
+ private static String readResource(String resource) {
+ try (InputStream inputStream =
+
CasbinAuthorizationManager.class.getClassLoader().getResourceAsStream(resource);
+ InputStreamReader inputStreamReader =
+ new InputStreamReader(
+ Objects.requireNonNull(inputStream, "Missing resource: " +
resource),
+ StandardCharsets.UTF_8);
+ BufferedReader bufferedReader = new BufferedReader(inputStreamReader))
{
+ return
bufferedReader.lines().collect(Collectors.joining(System.lineSeparator()));
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to load authorization resource: " +
resource, e);
+ }
+ }
+}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/authorization/DashboardPrivilegeMapper.java
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/DashboardPrivilegeMapper.java
new file mode 100644
index 000000000..dbe752ec5
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/DashboardPrivilegeMapper.java
@@ -0,0 +1,159 @@
+/*
+ * 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 io.javalin.http.Context;
+import org.apache.amoro.shade.guava32.com.google.common.base.Preconditions;
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class DashboardPrivilegeMapper {
+ private static final String MAPPING_RESOURCE =
"authorization/privilege_mapping.yaml";
+
+ private final List<DashboardPrivilegeMappingRule> mappingRules;
+
+ public DashboardPrivilegeMapper() {
+ this.mappingRules = loadRules();
+ }
+
+ public Optional<AuthorizationRequest> resolve(Context ctx) {
+ String method = ctx.method().toUpperCase();
+ String path = ctx.path();
+ return mappingRules.stream()
+ .filter(rule -> rule.matches(method, path))
+ .map(DashboardPrivilegeMappingRule::getAuthorizationRequest)
+ .findFirst();
+ }
+
+ private static List<DashboardPrivilegeMappingRule> loadRules() {
+ Map<String, Object> root = loadYaml(MAPPING_RESOURCE);
+ Object mappingsObject = root.get("mappings");
+ if (!(mappingsObject instanceof List)) {
+ throw new IllegalArgumentException(
+ "Invalid dashboard privilege mapping resource: missing mappings
list");
+ }
+
+ List<?> mappings = (List<?>) mappingsObject;
+ return mappings.stream()
+ .map(item -> toRule(castMap(item, "mapping entry")))
+ .collect(Collectors.toList());
+ }
+
+ private static DashboardPrivilegeMappingRule toRule(Map<String, Object>
ruleMap) {
+ Set<String> methods = toMethodSet(ruleMap.get("methods"));
+ Set<String> paths = toStringSet(ruleMap.get("paths"));
+ Set<String> prefixes = toStringSet(ruleMap.get("prefixes"));
+ String resourceTypeValue = requireString(ruleMap, "resource-type");
+ String privilegeValue = requireString(ruleMap, "privilege");
+ String resourceId =
+ Optional.ofNullable(ruleMap.get("resource-id"))
+ .map(String::valueOf)
+ .map(String::trim)
+ .filter(value -> !value.isEmpty())
+ .orElse(AuthorizationRequest.GLOBAL_RESOURCE_ID);
+ Preconditions.checkArgument(
+ !paths.isEmpty() || !prefixes.isEmpty(),
+ "Dashboard privilege mapping requires at least one path or prefix");
+
+ AuthorizationRequest request =
+ AuthorizationRequest.of(
+ ResourceType.valueOf(resourceTypeValue.trim().toUpperCase()),
+ resourceId,
+ Privilege.valueOf(privilegeValue.trim().toUpperCase()));
+ return new DashboardPrivilegeMappingRule(methods, paths, prefixes,
request);
+ }
+
+ private static Set<String> toStringSet(Object value) {
+ if (value == null) {
+ return Collections.emptySet();
+ }
+ if (!(value instanceof List)) {
+ throw new IllegalArgumentException(
+ "Invalid dashboard privilege mapping resource: expected list but
found " + value);
+ }
+ return ((List<?>) value)
+ .stream()
+ .map(String::valueOf)
+ .map(String::trim)
+ .filter(item -> !item.isEmpty())
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ private static Set<String> toMethodSet(Object value) {
+ return toStringSet(value).stream()
+ .map(String::toUpperCase)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ private static String requireString(Map<String, Object> source, String key) {
+ Object value = source.get(key);
+ Preconditions.checkArgument(value != null, "Missing required mapping
field: %s", key);
+ String text = String.valueOf(value).trim();
+ Preconditions.checkArgument(!text.isEmpty(), "Missing required mapping
field: %s", key);
+ return text;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, Object> castMap(Object value, String label) {
+ if (!(value instanceof Map)) {
+ throw new IllegalArgumentException(
+ "Invalid dashboard privilege mapping resource: " + label + " must be
a map");
+ }
+ return (Map<String, Object>) value;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, Object> loadYaml(String resource) {
+ Yaml yaml = new Yaml();
+ String content = readResource(resource);
+ Object loaded = yaml.load(content);
+ if (!(loaded instanceof Map)) {
+ throw new IllegalArgumentException(
+ "Invalid dashboard privilege mapping resource: root must be a map");
+ }
+ return (Map<String, Object>) loaded;
+ }
+
+ private static String readResource(String resource) {
+ try (InputStream inputStream =
+
DashboardPrivilegeMapper.class.getClassLoader().getResourceAsStream(resource);
+ InputStreamReader inputStreamReader =
+ new InputStreamReader(
+ Objects.requireNonNull(inputStream, "Missing resource: " +
resource),
+ StandardCharsets.UTF_8);
+ BufferedReader bufferedReader = new BufferedReader(inputStreamReader))
{
+ return
bufferedReader.lines().collect(Collectors.joining(System.lineSeparator()));
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to load authorization resource: " +
resource, e);
+ }
+ }
+}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/authorization/DashboardPrivilegeMappingRule.java
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/DashboardPrivilegeMappingRule.java
new file mode 100644
index 000000000..c726625ee
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/DashboardPrivilegeMappingRule.java
@@ -0,0 +1,50 @@
+/*
+ * 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 java.util.Collections;
+import java.util.Set;
+
+class DashboardPrivilegeMappingRule {
+ private final Set<String> methods;
+ private final Set<String> paths;
+ private final Set<String> prefixes;
+ private final AuthorizationRequest authorizationRequest;
+
+ DashboardPrivilegeMappingRule(
+ Set<String> methods,
+ Set<String> paths,
+ Set<String> prefixes,
+ AuthorizationRequest authorizationRequest) {
+ this.methods = methods == null ? Collections.emptySet() : methods;
+ this.paths = paths == null ? Collections.emptySet() : paths;
+ this.prefixes = prefixes == null ? Collections.emptySet() : prefixes;
+ this.authorizationRequest = authorizationRequest;
+ }
+
+ boolean matches(String method, String path) {
+ boolean methodMatched = methods.isEmpty() || methods.contains(method);
+ boolean pathMatched = paths.contains(path) ||
prefixes.stream().anyMatch(path::startsWith);
+ return methodMatched && pathMatched;
+ }
+
+ AuthorizationRequest getAuthorizationRequest() {
+ return authorizationRequest;
+ }
+}
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 000000000..9f2829683
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/LdapGroupRoleResolver.java
@@ -0,0 +1,239 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+class LdapGroupRoleResolver {
+ private static final Logger LOG =
LoggerFactory.getLogger(LdapGroupRoleResolver.class);
+
+ interface GroupMemberLoader {
+ Set<String> loadMembers(String groupDn, String memberAttribute) throws
NamingException;
+ }
+
+ private final boolean enabled;
+ private final String memberAttribute;
+ private final MessageFormat userDnFormatter;
+ private final Map<String, String> groupRoleMappings;
+ private final GroupMemberLoader groupMemberLoader;
+
+ static LdapGroupRoleResolver disabled() {
+ return new LdapGroupRoleResolver();
+ }
+
+ LdapGroupRoleResolver(Configurations conf) {
+ this(conf, new JndiGroupMemberLoader(conf));
+ }
+
+ private LdapGroupRoleResolver() {
+ this.enabled = false;
+ this.memberAttribute = "";
+ this.userDnFormatter = new MessageFormat("{0}");
+ this.groupRoleMappings = Collections.emptyMap();
+ this.groupMemberLoader = (groupDn, attribute) -> Collections.emptySet();
+ }
+
+ LdapGroupRoleResolver(Configurations conf, GroupMemberLoader
groupMemberLoader) {
+ this.enabled =
conf.get(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_ENABLED);
+ this.groupMemberLoader = groupMemberLoader;
+ if (!enabled) {
+ this.memberAttribute = "";
+ this.userDnFormatter = new MessageFormat("{0}");
+ this.groupRoleMappings = Collections.emptyMap();
+ return;
+ }
+
+ String ldapUrl =
conf.get(AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_LDAP_URL);
+ PreconditionUtils.checkNotNullOrEmpty(
+ ldapUrl, AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_LDAP_URL.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);
+ this.groupRoleMappings = loadGroupRoleMappings(conf);
+ }
+
+ Set<String> resolve(String username) {
+ if (!enabled) {
+ return Collections.emptySet();
+ }
+
+ String userDn = userDnFormatter.format(new String[] {username});
+ Set<String> resolvedRoles = new HashSet<>();
+ for (Map.Entry<String, String> entry : groupRoleMappings.entrySet()) {
+ String groupDn = entry.getKey();
+ try {
+ Set<String> members = groupMemberLoader.loadMembers(groupDn,
memberAttribute);
+ if (members.stream().anyMatch(member -> matchesMember(username,
userDn, member))) {
+ resolvedRoles.add(entry.getValue());
+ }
+ } catch (NamingException e) {
+ LOG.error("Failed to query LDAP group {} for user {}", groupDn,
username, e);
+ throw new RuntimeException(
+ "LDAP role resolution failed while querying group '" + groupDn +
"'", e);
+ }
+ }
+ return resolvedRoles;
+ }
+
+ private static Map<String, String> loadGroupRoleMappings(Configurations
conf) {
+ List<Map<String, String>> groups =
+
conf.getOptional(AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_GROUPS)
+ .orElse(Collections.emptyList());
+ return groups.stream()
+ .filter(LdapGroupRoleResolver::hasRequiredGroupFields)
+ .collect(
+ Collectors.toMap(
+ group -> String.valueOf(group.get("group-dn")).trim(),
+ group ->
+ parseRoleName(
+ String.valueOf(group.get("group-dn")),
String.valueOf(group.get("role"))),
+ (existing, replacement) -> replacement,
+ LinkedHashMap::new));
+ }
+
+ private static boolean hasRequiredGroupFields(Map<String, String> group) {
+ if (group.get("group-dn") == null || group.get("role") == null) {
+ LOG.warn(
+ "Ignore invalid http-server.authorization.ldap-role-mapping.groups
entry: {}", group);
+ return false;
+ }
+ return true;
+ }
+
+ private static String parseRoleName(String groupDn, String roleValue) {
+ String normalizedRole = roleValue == null ? "" : roleValue.trim();
+ if (normalizedRole.isEmpty()) {
+ throw new IllegalArgumentException(
+ String.format("Invalid empty role configured for LDAP group mapping
'%s'", groupDn));
+ }
+ return normalizedRole;
+ }
+
+ private static boolean matchesMember(String username, String userDn, String
member) {
+ if (member == null) {
+ return false;
+ }
+
+ String normalized = member.trim();
+ // Support DN-style, plain username, and uid= prefix formats commonly
returned by LDAP groups.
+ return normalized.equalsIgnoreCase(userDn)
+ || normalized.equalsIgnoreCase(username)
+ || normalized.equalsIgnoreCase("uid=" + username)
+ || extractCnFromDn(normalized).equalsIgnoreCase(username);
+ }
+
+ 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<String> loadMembers(String groupDn, String memberAttribute)
throws NamingException {
+ Hashtable<String, Object> 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);
+ 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);
+ }
+ }
+ }
+ }
+
+ private static Set<String> extractMembers(Attributes attributes, String
memberAttribute)
+ throws NamingException {
+ Attribute memberValues = attributes.get(memberAttribute);
+ if (memberValues == null) {
+ return Collections.emptySet();
+ }
+
+ Set<String> 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/Privilege.java
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/Privilege.java
new file mode 100644
index 000000000..d7aa4cd97
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/Privilege.java
@@ -0,0 +1,31 @@
+/*
+ * 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 Privilege {
+ VIEW_SYSTEM,
+ VIEW_CATALOG,
+ VIEW_TABLE,
+ VIEW_OPTIMIZER,
+ MANAGE_CATALOG,
+ MANAGE_TABLE,
+ MANAGE_OPTIMIZER,
+ EXECUTE_SQL,
+ MANAGE_PLATFORM
+}
diff --git
a/amoro-ams/src/main/java/org/apache/amoro/server/authorization/ResourceType.java
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/ResourceType.java
new file mode 100644
index 000000000..aa952ab0d
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/ResourceType.java
@@ -0,0 +1,28 @@
+/*
+ * 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 ResourceType {
+ SYSTEM,
+ CATALOG,
+ TABLE,
+ OPTIMIZER,
+ TERMINAL,
+ PLATFORM
+}
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 000000000..c063d3584
--- /dev/null
+++ b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/Role.java
@@ -0,0 +1,27 @@
+/*
+ * 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;
+
+/** Build-in Roles */
+public final class Role {
+ public static final String SERVICE_ADMIN = "SERVICE_ADMIN";
+ public static final String VIEWER = "VIEWER";
+
+ private Role() {}
+}
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 000000000..af8971cc7
--- /dev/null
+++
b/amoro-ams/src/main/java/org/apache/amoro/server/authorization/RoleResolver.java
@@ -0,0 +1,87 @@
+/*
+ * 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.LinkedHashSet;
+import java.util.Set;
+
+public class RoleResolver {
+ private static final Logger LOG =
LoggerFactory.getLogger(RoleResolver.class);
+
+ private final boolean authorizationEnabled;
+ private final String defaultRole;
+ 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
+ .getOptional(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE)
+ .map(String::trim)
+ .filter(role -> !role.isEmpty())
+ .orElse(null);
+ 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();
+ }
+
+ public boolean isAuthorizationEnabled() {
+ return authorizationEnabled;
+ }
+
+ public Set<String> resolve(String username) {
+ if (!authorizationEnabled) {
+ return Collections.singleton(Role.SERVICE_ADMIN);
+ }
+
+ Set<String> roles = new LinkedHashSet<>();
+ if (adminUsername.equals(username)) {
+ roles.add(Role.SERVICE_ADMIN);
+ }
+ roles.addAll(ldapGroupRoleResolver.resolve(username));
+ if (roles.isEmpty() && defaultRole != null) {
+ roles.add(defaultRole);
+ }
+
+ return Collections.unmodifiableSet(roles);
+ }
+}
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 e465e65e7..2246cf68b 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,10 @@ import org.apache.amoro.server.AmoroManagementConf;
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.AuthorizationRequest;
+import org.apache.amoro.server.authorization.CasbinAuthorizationManager;
+import org.apache.amoro.server.authorization.DashboardPrivilegeMapper;
+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;
@@ -78,6 +82,8 @@ 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";
private final CatalogController catalogController;
private final HealthCheckController healthCheckController;
private final LoginController loginController;
@@ -94,6 +100,8 @@ public class DashboardServer {
private final PasswdAuthenticationProvider basicAuthProvider;
private final TokenAuthenticationProvider jwtAuthProvider;
private final String proxyClientIpHeader;
+ private final CasbinAuthorizationManager authorizationManager;
+ private final DashboardPrivilegeMapper privilegeMapper;
public DashboardServer(
Configurations serviceConfig,
@@ -105,7 +113,9 @@ public class DashboardServer {
PlatformFileManager platformFileManager = new PlatformFileManager();
this.catalogController = new CatalogController(catalogManager,
platformFileManager);
this.healthCheckController = new HealthCheckController(ams);
- this.loginController = new LoginController(serviceConfig);
+ RoleResolver roleResolver = new RoleResolver(serviceConfig);
+ this.authorizationManager = new CasbinAuthorizationManager(serviceConfig);
+ this.loginController = new LoginController(serviceConfig, roleResolver,
authorizationManager);
this.optimizerGroupController = new OptimizerGroupController(tableManager,
optimizerManager);
this.optimizerController = new OptimizerController(optimizerManager);
this.platformFileInfoController = new
PlatformFileInfoController(platformFileManager);
@@ -120,6 +130,7 @@ public class DashboardServer {
this.overviewController = new OverviewController(manager);
APITokenManager apiTokenManager = new APITokenManager();
this.apiTokenController = new ApiTokenController(apiTokenManager);
+ this.privilegeMapper = new DashboardPrivilegeMapper();
String authType =
serviceConfig.get(AmoroManagementConf.HTTP_SERVER_REST_AUTH_TYPE);
this.basicAuthProvider =
@@ -408,8 +419,30 @@ public class DashboardServer {
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 (authorizationManager.isAuthorizationEnabled()) {
+ AuthorizationRequest request =
+ privilegeMapper
+ .resolve(ctx)
+ .orElseThrow(
+ () ->
+ new ForbiddenException(
+ "No authorization mapping for request: "
+ + ctx.method()
+ + " "
+ + uriPath));
+ if (!authorizationManager.authorize(user.getRoles(), request)) {
+ LOG.warn(
+ "Reject unauthorized request for user {}, URI: {}, method: {},
roles: {}",
+ user.getUserName(),
+ uriPath,
+ ctx.method(),
+ user.getRoles());
+ throw new ForbiddenException(NO_PERMISSION_MESSAGE);
+ }
}
return;
}
@@ -439,7 +472,7 @@ public class DashboardServer {
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",
""));
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 4d39705c0..4006d3df7 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,24 +24,38 @@ import org.apache.amoro.config.Configurations;
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.CasbinAuthorizationManager;
+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.Collections;
+import java.util.LinkedHashSet;
import java.util.Map;
+import java.util.Set;
/** The controller that handles login requests. */
public class LoginController {
public static final Logger LOG =
LoggerFactory.getLogger(LoginController.class);
private final PasswdAuthenticationProvider loginAuthProvider;
+ private final RoleResolver roleResolver;
+ private final CasbinAuthorizationManager authorizationManager;
- public LoginController(Configurations serviceConfig) {
+ public LoginController(
+ Configurations serviceConfig,
+ RoleResolver roleResolver,
+ CasbinAuthorizationManager authorizationManager) {
this.loginAuthProvider =
HttpAuthenticationFactory.getPasswordAuthenticationProvider(
serviceConfig.get(AmoroManagementConf.HTTP_SERVER_LOGIN_AUTH_PROVIDER),
serviceConfig);
+ this.roleResolver = roleResolver;
+ this.authorizationManager = authorizationManager;
}
/** Get current user. */
@@ -59,15 +73,37 @@ public class LoginController {
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();
+ Set<String> roles;
+ try {
+ roles = 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");
+ }
+
+ Set<String> privileges = authorizationManager.resolvePrivileges(roles);
+ SessionInfo sessionInfo =
+ new SessionInfo(authenticatedUser, System.currentTimeMillis() + "",
roles, privileges);
+ ctx.sessionAttribute("user", sessionInfo);
+ ctx.json(OkResponse.of(sessionInfo));
}
/** handle logout post request. */
@@ -76,29 +112,43 @@ public class LoginController {
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;
+ Set<String> roles;
+ Set<String> privileges;
- public SessionInfo(String username, String loginTime) {
+ public SessionInfo(
+ String username, String loginTime, Set<String> roles, Set<String>
privileges) {
this.userName = username;
this.loginTime = loginTime;
+ this.roles = Collections.unmodifiableSet(new LinkedHashSet<>(roles));
+ this.privileges = Collections.unmodifiableSet(new
LinkedHashSet<>(privileges));
}
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 Set<String> getRoles() {
+ return roles;
+ }
+
+ public Set<String> getPrivileges() {
+ return privileges;
+ }
+
+ public String getRole() {
+ if (roles.contains(Role.SERVICE_ADMIN)) {
+ return Role.SERVICE_ADMIN;
+ }
+
+ return roles.stream().findFirst().orElse(null);
}
}
}
diff --git a/amoro-ams/src/main/resources/authorization/amoro_rbac_model.conf
b/amoro-ams/src/main/resources/authorization/amoro_rbac_model.conf
new file mode 100644
index 000000000..4a755e9a3
--- /dev/null
+++ b/amoro-ams/src/main/resources/authorization/amoro_rbac_model.conf
@@ -0,0 +1,28 @@
+# 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.
+
+[request_definition]
+r = sub, resType, resId, act
+
+[policy_definition]
+p = sub, resType, resId, act, eft
+
+[policy_effect]
+e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
+
+[matchers]
+m = r.sub == p.sub && r.resType == p.resType && r.resId == p.resId && r.act ==
p.act
diff --git a/amoro-ams/src/main/resources/authorization/amoro_rbac_policy.csv
b/amoro-ams/src/main/resources/authorization/amoro_rbac_policy.csv
new file mode 100644
index 000000000..58d073260
--- /dev/null
+++ b/amoro-ams/src/main/resources/authorization/amoro_rbac_policy.csv
@@ -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.
+
+# Default viewer policy
+p, VIEWER, CATALOG, GLOBAL, VIEW_CATALOG, allow
+p, VIEWER, TABLE, GLOBAL, VIEW_TABLE, allow
+p, VIEWER, OPTIMIZER, GLOBAL, VIEW_OPTIMIZER, allow
+
+# Service admin policy
+p, SERVICE_ADMIN, SYSTEM, GLOBAL, VIEW_SYSTEM, allow
+p, SERVICE_ADMIN, CATALOG, GLOBAL, VIEW_CATALOG, allow
+p, SERVICE_ADMIN, TABLE, GLOBAL, VIEW_TABLE, allow
+p, SERVICE_ADMIN, OPTIMIZER, GLOBAL, VIEW_OPTIMIZER, allow
+p, SERVICE_ADMIN, CATALOG, GLOBAL, MANAGE_CATALOG, allow
+p, SERVICE_ADMIN, TABLE, GLOBAL, MANAGE_TABLE, allow
+p, SERVICE_ADMIN, OPTIMIZER, GLOBAL, MANAGE_OPTIMIZER, allow
+p, SERVICE_ADMIN, TERMINAL, GLOBAL, EXECUTE_SQL, allow
+p, SERVICE_ADMIN, PLATFORM, GLOBAL, MANAGE_PLATFORM, allow
diff --git a/amoro-ams/src/main/resources/authorization/privilege_mapping.yaml
b/amoro-ams/src/main/resources/authorization/privilege_mapping.yaml
new file mode 100644
index 000000000..b0a32fe9a
--- /dev/null
+++ b/amoro-ams/src/main/resources/authorization/privilege_mapping.yaml
@@ -0,0 +1,97 @@
+# 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.
+
+mappings:
+ - prefixes:
+ - /api/ams/v1/overview
+ methods:
+ - GET
+ resource-type: SYSTEM
+ privilege: VIEW_SYSTEM
+
+ - prefixes:
+ - /api/ams/v1/catalogs
+ methods:
+ - GET
+ resource-type: CATALOG
+ privilege: VIEW_CATALOG
+
+ - prefixes:
+ - /api/ams/v1/catalogs
+ methods:
+ - POST
+ - PUT
+ - DELETE
+ resource-type: CATALOG
+ privilege: MANAGE_CATALOG
+
+ - prefixes:
+ - /api/ams/v1/tables
+ methods:
+ - GET
+ resource-type: TABLE
+ privilege: VIEW_TABLE
+
+ - prefixes:
+ - /api/ams/v1/tables
+ methods:
+ - POST
+ - PUT
+ - DELETE
+ resource-type: TABLE
+ privilege: MANAGE_TABLE
+
+ - paths:
+ - /api/ams/v1/upgrade/properties
+ methods:
+ - POST
+ - PUT
+ - DELETE
+ resource-type: TABLE
+ privilege: MANAGE_TABLE
+
+ - prefixes:
+ - /api/ams/v1/optimize
+ methods:
+ - GET
+ resource-type: OPTIMIZER
+ privilege: VIEW_OPTIMIZER
+
+ - prefixes:
+ - /api/ams/v1/optimize
+ methods:
+ - POST
+ - PUT
+ - DELETE
+ resource-type: OPTIMIZER
+ privilege: MANAGE_OPTIMIZER
+
+ - prefixes:
+ - /api/ams/v1/terminal
+ methods:
+ - GET
+ - POST
+ - PUT
+ resource-type: TERMINAL
+ privilege: EXECUTE_SQL
+
+ - prefixes:
+ - /api/ams/v1/files
+ - /api/ams/v1/settings
+ - /api/ams/v1/api/token
+ resource-type: PLATFORM
+ privilege: MANAGE_PLATFORM
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 1e9a9df41..bbd1a45ba 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.Path;
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,94 @@ public class ConfigurationsTest {
public static String UPDATE_CMD =
"UPDATE=1 ./mvnw test -pl amoro-ams -am -Dtest=ConfigurationsTest";
+ private static final List<String> RBAC_EXAMPLE =
+ Arrays.asList(
+ "## RBAC Example",
+ "",
+ "Enable RBAC only when you need role separation for dashboard
users.",
+ "",
+ "The current RBAC model uses:",
+ "",
+ "- string-based roles",
+ "- LDAP group-to-role mapping as the primary role source",
+ "- built-in Casbin policy to translate roles into privileges",
+ "- privilege-driven frontend authorization",
+ "",
+ "Amoro provides two built-in roles by default:",
+ "",
+ "| Role | Description | Default Privileges |",
+ "| --- | --- | --- |",
+ "| `SERVICE_ADMIN` | Platform administrator | All privileges |",
+ "| `VIEWER` | Read-only resource viewer | `VIEW_CATALOG`,
`VIEW_TABLE`, `VIEW_OPTIMIZER` |",
+ "",
+ "`VIEWER` does not include `VIEW_SYSTEM`, so it cannot access
`Overview` or `Terminal`.",
+ "After login succeeds, `/login/current` returns both `roles` and
effective `privileges`.",
+ "",
+ "If you need additional roles, define them by Casbin policy and map
LDAP groups to those",
+ "role names. The role name itself does not need to be added to Java
enum code.",
+ "",
+ "```yaml",
+ "ams:",
+ " http-server:",
+ " authorization:",
+ " enabled: true",
+ "```",
+ "",
+ "```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",
+ " ldap-role-mapping:",
+ " enabled: true",
+ " 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\"",
+ " groups:",
+ " - group-dn:
\"cn=amoro-service-admins,ou=groups,dc=example,dc=com\"",
+ " role: SERVICE_ADMIN",
+ " - group-dn:
\"cn=amoro-viewers,ou=groups,dc=example,dc=com\"",
+ " role: VIEWER",
+ " - group-dn:
\"cn=amoro-catalog-admins,ou=groups,dc=example,dc=com\"",
+ " role: CATALOG_ADMIN",
+ "```",
+ "",
+ "Example `/login/current` response:",
+ "",
+ "```json",
+ "{",
+ " \"userName\": \"alice\",",
+ " \"roles\": [\"CATALOG_ADMIN\"],",
+ " \"privileges\": [",
+ " \"VIEW_CATALOG\",",
+ " \"MANAGE_CATALOG\",",
+ " \"VIEW_TABLE\",",
+ " \"MANAGE_TABLE\"",
+ " ]",
+ "}",
+ "```",
+ "",
+ "Example custom role policy:",
+ "",
+ "```csv",
+ "p, CATALOG_ADMIN, CATALOG, GLOBAL, VIEW_CATALOG, allow",
+ "p, CATALOG_ADMIN, CATALOG, GLOBAL, MANAGE_CATALOG, allow",
+ "p, CATALOG_ADMIN, TABLE, GLOBAL, VIEW_TABLE, allow",
+ "p, CATALOG_ADMIN, TABLE, GLOBAL, MANAGE_TABLE, allow",
+ "```",
+ "",
+ "Notes:",
+ "",
+ "- Recommended production setup is explicit role assignment only.",
+ "- `default-role` is optional. If it is not set, users who do not
match any role mapping get no business role.",
+ "- Use `default-role: VIEWER` only if you intentionally want
authenticated users without a matched role mapping to receive read-only
access.",
+ "- Casbin model and default policy are built into the service and
loaded from classpath.",
+ "- Dashboard request-to-privilege mapping is also built into the
service and loaded from a resource configuration file.");
+
@Test
public void testAmoroManagementConfDocumentation() throws Exception {
List<AmoroConfInfo> confInfoList = new ArrayList<>();
@@ -66,7 +156,8 @@ public class ConfigurationsTest {
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 +238,12 @@ public class ConfigurationsTest {
// 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 +396,21 @@ public class ConfigurationsTest {
Class<?> confClass;
String title;
String description;
+ List<String> 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<String>
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/authorization/CasbinAuthorizationManagerTest.java
b/amoro-ams/src/test/java/org/apache/amoro/server/authorization/CasbinAuthorizationManagerTest.java
new file mode 100644
index 000000000..9e4aa5d5b
--- /dev/null
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/authorization/CasbinAuthorizationManagerTest.java
@@ -0,0 +1,47 @@
+/*
+ * 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.assertFalse;
+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 java.util.Set;
+
+public class CasbinAuthorizationManagerTest {
+
+ @Test
+ public void testResolvePrivilegesForBuiltInRoles() {
+ Configurations conf = new Configurations();
+ conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true);
+
+ CasbinAuthorizationManager manager = new CasbinAuthorizationManager(conf);
+
+ Set<String> viewerPrivileges =
manager.resolvePrivileges(Set.of(Role.VIEWER));
+ assertTrue(viewerPrivileges.contains(Privilege.VIEW_TABLE.name()));
+ assertFalse(viewerPrivileges.contains(Privilege.VIEW_SYSTEM.name()));
+
+ Set<String> adminPrivileges =
manager.resolvePrivileges(Set.of(Role.SERVICE_ADMIN));
+ assertTrue(adminPrivileges.contains(Privilege.VIEW_SYSTEM.name()));
+ assertTrue(adminPrivileges.contains(Privilege.EXECUTE_SQL.name()));
+ }
+}
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 000000000..e63411e30
--- /dev/null
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/authorization/LdapGroupRoleResolverTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+public class LdapGroupRoleResolverTest {
+
+ @Test
+ public void testResolveRoleFromUserDnMember() {
+ LdapGroupRoleResolver resolver =
+ new LdapGroupRoleResolver(
+ baseConfig(),
+ (groupDn, memberAttribute) ->
+
"cn=amoro-service-admins,ou=groups,dc=example,dc=com".equals(groupDn)
+ ?
Collections.singleton("uid=alice,ou=people,dc=example,dc=com")
+ : Collections.emptySet());
+
+ assertEquals(Collections.singleton(Role.SERVICE_ADMIN),
resolver.resolve("alice"));
+ }
+
+ @Test
+ public void testResolveRoleFromUsernameMember() {
+ LdapGroupRoleResolver resolver =
+ new LdapGroupRoleResolver(
+ baseConfig(),
+ (groupDn, memberAttribute) ->
+
"cn=amoro-service-admins,ou=groups,dc=example,dc=com".equals(groupDn)
+ ? Set.of("alice")
+ : Collections.emptySet());
+
+ assertEquals(Collections.singleton(Role.SERVICE_ADMIN),
resolver.resolve("alice"));
+ }
+
+ @Test
+ public void testResolveRoleFromCnDnMember() {
+ LdapGroupRoleResolver resolver =
+ new LdapGroupRoleResolver(
+ baseConfig(),
+ (groupDn, memberAttribute) ->
+
"cn=amoro-service-admins,ou=groups,dc=example,dc=com".equals(groupDn)
+ ? Set.of("CN=alice,OU=Employees,OU=Cisco
Users,DC=cisco,DC=com")
+ : Collections.emptySet());
+
+ assertEquals(Collections.singleton(Role.SERVICE_ADMIN),
resolver.resolve("alice"));
+ }
+
+ @Test
+ public void testResolveRoleFromMemberUidStyleValue() {
+ LdapGroupRoleResolver resolver =
+ new LdapGroupRoleResolver(
+ baseConfig(),
+ (groupDn, memberAttribute) ->
+
"cn=amoro-service-admins,ou=groups,dc=example,dc=com".equals(groupDn)
+ ? Set.of("uid=alice")
+ : Collections.emptySet());
+
+ assertEquals(Collections.singleton(Role.SERVICE_ADMIN),
resolver.resolve("alice"));
+ }
+
+ @Test
+ public void testResolveRoleIsCaseInsensitiveForDnMembers() {
+ LdapGroupRoleResolver resolver =
+ new LdapGroupRoleResolver(
+ baseConfig(),
+ (groupDn, memberAttribute) ->
+
"cn=amoro-service-admins,ou=groups,dc=example,dc=com".equals(groupDn)
+ ? Set.of("UID=Alice,OU=People,DC=Example,DC=Com")
+ : Collections.emptySet());
+
+ assertEquals(Collections.singleton(Role.SERVICE_ADMIN),
resolver.resolve("alice"));
+ }
+
+ @Test
+ public void testResolveViewerGroupRole() {
+ LdapGroupRoleResolver resolver =
+ new LdapGroupRoleResolver(
+ baseConfig(),
+ (groupDn, memberAttribute) ->
+ "cn=amoro-viewers,ou=groups,dc=example,dc=com".equals(groupDn)
+ ? Set.of("bob")
+ : Collections.emptySet());
+
+ assertEquals(Collections.singleton(Role.VIEWER), resolver.resolve("bob"));
+ }
+
+ @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_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));
+ }
+
+ private static Configurations baseConfig() {
+ Configurations conf = new Configurations();
+ conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true);
+ 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_USER_DN_PATTERN,
+ "uid={0},ou=people,dc=example,dc=com");
+ conf.set(
+ AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_GROUPS,
+ Arrays.asList(
+ group("cn=amoro-service-admins,ou=groups,dc=example,dc=com",
"SERVICE_ADMIN"),
+ group("cn=amoro-viewers,ou=groups,dc=example,dc=com", "VIEWER")));
+ return conf;
+ }
+
+ private static Map<String, String> group(String groupDn, String role) {
+ return Map.of("group-dn", groupDn, "role", role);
+ }
+}
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 000000000..4b85ddec0
--- /dev/null
+++
b/amoro-ams/src/test/java/org/apache/amoro/server/authorization/RoleResolverTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.assertTrue;
+
+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.Map;
+import java.util.Set;
+
+public class RoleResolverTest {
+
+ @Test
+ public void testDisabledAuthorizationDefaultsToServiceAdmin() {
+ Configurations conf = new Configurations();
+
+ RoleResolver resolver = new RoleResolver(conf);
+
+ assertEquals(Collections.singleton(Role.SERVICE_ADMIN),
resolver.resolve("viewer"));
+ }
+
+ @Test
+ public void testResolveBootstrapAdminAndDefaultViewerRole() {
+ Configurations conf = new Configurations();
+ conf.set(AmoroManagementConf.ADMIN_USERNAME, "admin");
+ conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true);
+ conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.VIEWER);
+
+ RoleResolver resolver = new RoleResolver(conf);
+
+ assertEquals(Collections.singleton(Role.SERVICE_ADMIN),
resolver.resolve("admin"));
+ assertEquals(Collections.singleton(Role.VIEWER),
resolver.resolve("alice"));
+ }
+
+ @Test
+ public void testResolveLdapGroupRoles() {
+ Configurations conf = baseConfig();
+ RoleResolver resolver =
+ new RoleResolver(
+ conf,
+ new LdapGroupRoleResolver(
+ conf,
+ (groupDn, memberAttribute) ->
+
"cn=amoro-service-admins,ou=groups,dc=example,dc=com".equals(groupDn)
+ ?
Collections.singleton("uid=alice,ou=people,dc=example,dc=com")
+ : Collections.emptySet()));
+
+ assertEquals(Collections.singleton(Role.SERVICE_ADMIN),
resolver.resolve("alice"));
+ assertEquals(Collections.singleton(Role.VIEWER), resolver.resolve("bob"));
+ }
+
+ @Test
+ public void testDefaultRoleOnlyAppliesWhenNoExplicitRoleMatched() {
+ Configurations conf = baseConfig();
+ conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.VIEWER);
+ RoleResolver resolver =
+ new RoleResolver(
+ conf,
+ new LdapGroupRoleResolver(
+ conf,
+ (groupDn, memberAttribute) ->
+
"cn=amoro-service-admins,ou=groups,dc=example,dc=com".equals(groupDn)
+ ?
Collections.singleton("uid=admin,ou=people,dc=example,dc=com")
+ : Collections.emptySet()));
+
+ Set<String> adminRoles = resolver.resolve("admin");
+ assertEquals(1, adminRoles.size());
+ assertTrue(adminRoles.contains(Role.SERVICE_ADMIN));
+ }
+
+ private static Configurations baseConfig() {
+ Configurations conf = new Configurations();
+ conf.set(AmoroManagementConf.AUTHORIZATION_ENABLED, true);
+ 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_USER_DN_PATTERN,
+ "uid={0},ou=people,dc=example,dc=com");
+ conf.set(
+ AmoroManagementConf.AUTHORIZATION_LDAP_ROLE_MAPPING_GROUPS,
+ Arrays.asList(
+ group("cn=amoro-service-admins,ou=groups,dc=example,dc=com",
"SERVICE_ADMIN"),
+ group("cn=amoro-viewers,ou=groups,dc=example,dc=com", "VIEWER")));
+ conf.set(AmoroManagementConf.AUTHORIZATION_DEFAULT_ROLE, Role.VIEWER);
+ return conf;
+ }
+
+ private static Map<String, String> group(String groupDn, String role) {
+ return Map.of("group-dn", groupDn, "role", role);
+ }
+}
diff --git a/amoro-web/mock/modules/common.js b/amoro-web/mock/modules/common.js
index 089dea244..8a2267fd2 100644
--- a/amoro-web/mock/modules/common.js
+++ b/amoro-web/mock/modules/common.js
@@ -25,7 +25,9 @@ export default [
msg: 'success',
"result": {
"userName": "admin",
- "loginTime": "1703839452053"
+ "loginTime": "1703839452053",
+ "role": "SERVICE_ADMIN",
+ "roles": ["SERVICE_ADMIN"]
}
}),
},
@@ -35,7 +37,12 @@ export default [
response: () => ({
code: 200,
msg: 'success',
- result: 'success'
+ result: {
+ userName: 'admin',
+ loginTime: '1703839452053',
+ role: 'SERVICE_ADMIN',
+ roles: ['SERVICE_ADMIN']
+ }
}),
},
{
diff --git a/amoro-web/src/components/Sidebar.vue
b/amoro-web/src/components/Sidebar.vue
index 31cf72bee..f5cf27526 100644
--- a/amoro-web/src/components/Sidebar.vue
+++ b/amoro-web/src/components/Sidebar.vue
@@ -22,6 +22,15 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import useStore from '@/store/index'
import { getQueryString } from '@/utils'
+import {
+ canExecuteSql,
+ canManagePlatform,
+ canViewCatalog,
+ canViewOptimizer,
+ canViewSystem,
+ canViewTable,
+ getDefaultRoute,
+} from '@/utils/permission'
interface MenuItem {
key: string
@@ -44,6 +53,12 @@ export default defineComponent({
const hasToken = computed(() => {
return !!(getQueryString('token') || '')
})
+ const canAccessSystem = computed(() => canViewSystem())
+ const canAccessCatalog = computed(() => canViewCatalog())
+ const canAccessTable = computed(() => canViewTable())
+ const canAccessOptimizer = computed(() => canViewOptimizer())
+ const canAccessTerminal = computed(() => canExecuteSql())
+ const canAccessSettings = computed(() => canManagePlatform())
const menuList = computed(() => {
const menu: MenuItem[] = [
{
@@ -84,7 +99,28 @@ export default defineComponent({
icon: 'settings',
},
]
- return hasToken.value ? menu : allMenu
+ const source = hasToken.value ? menu : allMenu
+ return source.filter((item) => {
+ if (!canAccessSettings.value && item.key === 'settings') {
+ return false
+ }
+ if (!canAccessSystem.value && item.key === 'overview') {
+ return false
+ }
+ if (!canAccessTerminal.value && item.key === 'terminal') {
+ return false
+ }
+ if (!canAccessCatalog.value && item.key === 'catalogs') {
+ return false
+ }
+ if (!canAccessOptimizer.value && item.key === 'optimizing') {
+ return false
+ }
+ if (!canAccessTable.value && item.key === 'tables') {
+ return false
+ }
+ return true
+ })
})
const setCurMenu = () => {
@@ -157,13 +193,19 @@ export default defineComponent({
const viewOverview = () => {
router.push({
- path: '/overview',
+ path: canAccessSystem.value ? '/overview' : getDefaultRoute(),
})
}
return {
...toRefs(state),
hasToken,
+ canAccessSystem,
+ canAccessCatalog,
+ canAccessTable,
+ canAccessOptimizer,
+ canAccessTerminal,
+ canAccessSettings,
menuList,
toggleCollapsed,
navClick,
diff --git a/amoro-web/src/components/Topbar.vue
b/amoro-web/src/components/Topbar.vue
index 3e24f1a66..44abc0966 100644
--- a/amoro-web/src/components/Topbar.vue
+++ b/amoro-web/src/components/Topbar.vue
@@ -40,6 +40,9 @@ const verInfo = reactive<IVersion>({
const { t, locale } = useI18n()
const router = useRouter()
const store = useStore()
+function roleText() {
+ return (store.userInfo.roles || []).join(', ') || '-'
+}
async function getVersion() {
const res = await getVersionInfo()
@@ -65,6 +68,8 @@ async function handleLogout() {
finally {
store.updateUserInfo({
userName: '',
+ roles: [],
+ privileges: [],
})
goLoginPage()
}
@@ -97,7 +102,7 @@ onMounted(() => {
<span class="g-mr-8">{{ `${$t('commitTime')}: ${verInfo.commitTime}`
}}</span>
</div>
<a-dropdown>
- <span>{{ store.userInfo.userName }} <DownOutlined /></span>
+ <span><span class="role-badge">{{ roleText() }}</span>{{
store.userInfo.userName }} <DownOutlined /></span>
<template #overlay>
<a-menu>
<a-menu-item key="userGuide" @click="goDocs">
@@ -141,4 +146,12 @@ onMounted(() => {
.logout-button:hover {
border-color: unset;
}
+ .role-badge {
+ display: inline-block;
+ margin-right: 8px;
+ padding: 1px 6px;
+ border-radius: 10px;
+ background: #f0f5ff;
+ color: #1d39c4;
+ }
</style>
diff --git a/amoro-web/src/main.ts b/amoro-web/src/main.ts
index d7f8da460..58dd06d8d 100644
--- a/amoro-web/src/main.ts
+++ b/amoro-web/src/main.ts
@@ -33,6 +33,16 @@ import './assets/icons'
import loginService from './services/login.service'
import { getQueryString } from './utils'
import { resetLoginTip } from './utils/request'
+import {
+ canExecuteSql,
+ canManagePlatform,
+ canManageTable,
+ canViewCatalog,
+ canViewOptimizer,
+ canViewSystem,
+ canViewTable,
+ getDefaultRoute,
+} from './utils/permission'
import SvgIcon from '@/components/svg-icon.vue'
import 'virtual:svg-icons-register'
@@ -65,8 +75,13 @@ RegisterComponents(app);
const token = getQueryString('token') || ''
const res = await loginService.getCurUserInfo(token)
if (res) {
+ const roles = res.roles || (res.role ? [res.role] : [])
+ const privileges = res.privileges || []
store.updateUserInfo({
userName: res.userName,
+ role: roles[0],
+ roles,
+ privileges,
})
}
}
@@ -88,6 +103,57 @@ RegisterComponents(app);
})
return
}
+ if (
+ to.path === '/settings'
+ && !canManagePlatform()
+ ) {
+ next({
+ path: getDefaultRoute(),
+ })
+ return
+ }
+ if (to.path === '/hive-tables/upgrade' && !canManageTable()) {
+ next({
+ path: getDefaultRoute(),
+ })
+ return
+ }
+ if (to.path === '/overview' && !canViewSystem()) {
+ next({
+ path: getDefaultRoute(),
+ })
+ return
+ }
+ if (to.path === '/terminal' && !canExecuteSql()) {
+ next({
+ path: getDefaultRoute(),
+ })
+ return
+ }
+ if (to.path === '/catalogs' && !canViewCatalog()) {
+ next({
+ path: getDefaultRoute(),
+ })
+ return
+ }
+ if (to.path === '/optimizing' && !canViewOptimizer()) {
+ next({
+ path: getDefaultRoute(),
+ })
+ return
+ }
+ if ((to.path === '/tables' || to.path.startsWith('/tables/')) &&
!canViewTable()) {
+ next({
+ path: getDefaultRoute(),
+ })
+ return
+ }
+ if (to.path.startsWith('/hive-tables') && !canViewTable()) {
+ next({
+ path: getDefaultRoute(),
+ })
+ return
+ }
next()
})
diff --git a/amoro-web/src/router/index.ts b/amoro-web/src/router/index.ts
index 734395410..0c3bfd4eb 100644
--- a/amoro-web/src/router/index.ts
+++ b/amoro-web/src/router/index.ts
@@ -37,7 +37,7 @@ const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
- redirect: 'overview', // overview
+ redirect: 'tables',
component: Home,
children: [
{
diff --git a/amoro-web/src/store/index.ts b/amoro-web/src/store/index.ts
index 2f02b8cf9..7a7fb7978 100644
--- a/amoro-web/src/store/index.ts
+++ b/amoro-web/src/store/index.ts
@@ -23,6 +23,8 @@ function state(): GlobalState {
return {
userInfo: {
userName: '',
+ roles: [],
+ privileges: [],
// token: ''
} as UserInfo,
isShowTablesMenu: false,
diff --git a/amoro-web/src/types/common.type.ts
b/amoro-web/src/types/common.type.ts
index 073c78c58..2f5dff1c7 100644
--- a/amoro-web/src/types/common.type.ts
+++ b/amoro-web/src/types/common.type.ts
@@ -27,6 +27,9 @@ export interface IHttpResponse {
}
export interface UserInfo {
userName: string
+ role?: string
+ roles?: string[]
+ privileges?: string[]
token?: string
}
diff --git a/amoro-web/src/utils/permission.ts
b/amoro-web/src/utils/permission.ts
new file mode 100644
index 000000000..ca61d658a
--- /dev/null
+++ b/amoro-web/src/utils/permission.ts
@@ -0,0 +1,106 @@
+/*
+ * 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.
+ */
+
+import useStore from '@/store'
+
+export type UserPrivilege =
+ | 'VIEW_SYSTEM'
+ | 'VIEW_CATALOG'
+ | 'VIEW_TABLE'
+ | 'VIEW_OPTIMIZER'
+ | 'MANAGE_CATALOG'
+ | 'MANAGE_TABLE'
+ | 'MANAGE_OPTIMIZER'
+ | 'EXECUTE_SQL'
+ | 'MANAGE_PLATFORM'
+
+export function getRoles(): string[] {
+ const store = useStore()
+ const roles = store.userInfo.roles || []
+ if (roles.length) {
+ return roles
+ }
+ return store.userInfo.role ? [store.userInfo.role] : []
+}
+
+export function getPrivileges(): UserPrivilege[] {
+ const store = useStore()
+ return (store.userInfo.privileges || []) as UserPrivilege[]
+}
+
+export function hasPrivilege(privilege: UserPrivilege): boolean {
+ return getPrivileges().includes(privilege)
+}
+
+export function canViewSystem(): boolean {
+ return hasPrivilege('VIEW_SYSTEM')
+}
+
+export function canViewCatalog(): boolean {
+ return hasPrivilege('VIEW_CATALOG')
+}
+
+export function canManageCatalog(): boolean {
+ return hasPrivilege('MANAGE_CATALOG')
+}
+
+export function canViewTable(): boolean {
+ return hasPrivilege('VIEW_TABLE')
+}
+
+export function canManageTable(): boolean {
+ return hasPrivilege('MANAGE_TABLE')
+}
+
+export function canViewOptimizer(): boolean {
+ return hasPrivilege('VIEW_OPTIMIZER')
+}
+
+export function canManageOptimizer(): boolean {
+ return hasPrivilege('MANAGE_OPTIMIZER')
+}
+
+export function canExecuteSql(): boolean {
+ return hasPrivilege('EXECUTE_SQL')
+}
+
+export function canManagePlatform(): boolean {
+ return hasPrivilege('MANAGE_PLATFORM')
+}
+
+export function getDefaultRoute(): string {
+ if (canViewTable()) {
+ return '/tables'
+ }
+ if (canViewCatalog()) {
+ return '/catalogs'
+ }
+ if (canViewOptimizer()) {
+ return '/optimizing'
+ }
+ if (canViewSystem()) {
+ return '/overview'
+ }
+ if (canExecuteSql()) {
+ return '/terminal'
+ }
+ if (canManagePlatform()) {
+ return '/settings'
+ }
+ return '/login'
+}
diff --git a/amoro-web/src/utils/request.ts b/amoro-web/src/utils/request.ts
index 33beff375..0cb0683a8 100644
--- a/amoro-web/src/utils/request.ts
+++ b/amoro-web/src/utils/request.ts
@@ -159,18 +159,24 @@ const request: any = function (options:
CustomAxiosRequestConfig) {
}
// not login
if (code === 403) {
- const store = useStore()
- store.updateUserInfo({
- userName: '',
- })
- const currentPath = router.currentRoute.value.path
- if (requestConfig.handleError && currentPath !== '/login' &&
!loginTipShown) {
- message.error(msg || 'need login')
- loginTipShown = true
+ const needLogin = (msg || '').toLowerCase().includes('login')
+ if (needLogin) {
+ const store = useStore()
+ store.updateUserInfo({
+ userName: '',
+ roles: [],
+ privileges: [],
+ })
+ const currentPath = router.currentRoute.value.path
+ if (requestConfig.handleError && currentPath !== '/login' &&
!loginTipShown) {
+ message.error(msg || 'need login')
+ loginTipShown = true
+ }
+ return router.push({
+ path: '/login',
+ })
}
- return router.push({
- path: '/login',
- })
+ return Promise.reject(new Error(msg || 'No permission'))
}
return Promise.reject(new Error(msg || 'error'))
})
diff --git a/amoro-web/src/views/catalogs/Detail.vue
b/amoro-web/src/views/catalogs/Detail.vue
index 3eb09815a..634775686 100644
--- a/amoro-web/src/views/catalogs/Detail.vue
+++ b/amoro-web/src/views/catalogs/Detail.vue
@@ -34,6 +34,7 @@ import type { ICatalogItem, IIOptimizeGroupItem,
ILableAndValue, IMap } from '@/
import { usePlaceholder } from '@/hooks/usePlaceholder'
import { getResourceGroupsListAPI } from '@/services/optimize.service'
import { downloadWithHeader } from '@/utils/request'
+import { canManageCatalog } from '@/utils/permission'
interface IStorageConfigItem {
label: string
@@ -107,6 +108,7 @@ const isHiveMetastore = computed(() => {
return formState.catalog.type === 'hive'
})
const loading = ref<boolean>(false)
+const writable = computed(() => canManageCatalog())
const formRef = ref()
const propertiesRef = ref()
const tablePropertiesRef = ref()
@@ -778,7 +780,7 @@ onMounted(() => {
</a-form>
</div>
</div>
- <div v-if="isEdit" class="footer-btn">
+ <div v-if="isEdit && writable" class="footer-btn">
<a-button type="primary" class="save-btn g-mr-12" @click="handleSave">
{{ $t('save') }}
</a-button>
@@ -786,7 +788,7 @@ onMounted(() => {
{{ $t('cancel') }}
</a-button>
</div>
- <div v-if="!isEdit" class="footer-btn">
+ <div v-if="!isEdit && writable" class="footer-btn">
<a-button type="primary" class="edit-btn g-mr-12" @click="handleEdit">
{{ $t('edit') }}
</a-button>
diff --git a/amoro-web/src/views/catalogs/index.vue
b/amoro-web/src/views/catalogs/index.vue
index 61652a45b..743d12313 100644
--- a/amoro-web/src/views/catalogs/index.vue
+++ b/amoro-web/src/views/catalogs/index.vue
@@ -26,6 +26,7 @@ import Detail from './Detail.vue'
import { getCatalogList } from '@/services/table.service'
import type { ICatalogItem } from '@/types/common.type'
import { usePageScroll } from '@/hooks/usePageScroll'
+import { canManageCatalog } from '@/utils/permission'
const { t } = useI18n()
const router = useRouter()
@@ -39,6 +40,7 @@ const curCatalog = reactive<ICatalogItem>({
const isEdit = ref<boolean>(false)
const NEW_CATALOG = 'new catalog'
const loading = ref<boolean>(false)
+const writable = ref<boolean>(canManageCatalog())
const simpleImage = AEmpty.PRESENTED_IMAGE_SIMPLE
async function getCatalogs() {
@@ -136,7 +138,7 @@ function addCatalog() {
addNewCatalog()
}
}
-async function addNewCatalog() {
+ async function addNewCatalog() {
const item: ICatalogItem = {
catalogName: NEW_CATALOG,
catalogType: '',
@@ -183,7 +185,7 @@ onBeforeRouteLeave((_to, _form, next) => {
{{ item.catalogName }}
</li>
</ul>
- <a-button :disabled="curCatalog.catalogName === NEW_CATALOG"
class="add-btn" @click="addCatalog">
+ <a-button v-if="writable" :disabled="curCatalog.catalogName ===
NEW_CATALOG" class="add-btn" @click="addCatalog">
+
</a-button>
</div>
diff --git a/amoro-web/src/views/hive-details/index.vue
b/amoro-web/src/views/hive-details/index.vue
index 3e67c8c76..a23781ab1 100644
--- a/amoro-web/src/views/hive-details/index.vue
+++ b/amoro-web/src/views/hive-details/index.vue
@@ -25,6 +25,7 @@ import errorMsg from './components/ErrorMsg.vue'
import type { DetailColumnItem } from '@/types/common.type'
import { upgradeStatusMap } from '@/types/common.type'
import { getHiveTableDetail, getUpgradeStatus } from '@/services/table.service'
+import { canManageTable } from '@/utils/permission'
export default defineComponent({
name: 'Tables',
@@ -42,6 +43,7 @@ export default defineComponent({
const isSecondaryNav = computed(() => {
return !!(route.path.includes('upgrade'))
})
+ const writable = computed(() => canManageTable())
const state = reactive({
loading: false,
@@ -172,6 +174,7 @@ export default defineComponent({
upgradeTable,
goBack,
refresh,
+ writable,
}
},
})
@@ -183,7 +186,7 @@ export default defineComponent({
<div class="g-flex-jsb table-top">
<span :title="tableName" class="table-name g-text-nowrap">{{ tableName
}}</span>
<div class="right-btn">
- <a-button type="primary" :disabled="status ===
upgradeStatus.upgrading" @click="upgradeTable">
+ <a-button v-if="writable" type="primary" :disabled="status ===
upgradeStatus.upgrading" @click="upgradeTable">
{{ displayStatus }}
</a-button>
<p v-if="status === upgradeStatus.failed" class="fail-msg"
@click="showErrorMsg = true">
diff --git a/amoro-web/src/views/login/index.vue
b/amoro-web/src/views/login/index.vue
index fb897123e..cd1884823 100644
--- a/amoro-web/src/views/login/index.vue
+++ b/amoro-web/src/views/login/index.vue
@@ -22,6 +22,7 @@ import { defineComponent, reactive, ref, onMounted,
onUnmounted, nextTick } from
import { useRouter } from 'vue-router'
import loginService from '@/services/login.service'
import { usePlaceholder } from '@/hooks/usePlaceholder'
+import useStore from '@/store'
interface FormState {
username: string
@@ -32,6 +33,7 @@ export default defineComponent({
name: 'Login',
setup() {
const router = useRouter()
+ const store = useStore()
const formState = reactive<FormState>({
username: '',
password: '',
@@ -114,6 +116,15 @@ export default defineComponent({
message.error(res.message)
return
}
+ const result = res.result || {}
+ const roles = result.roles || (result.role ? [result.role] : [])
+ const privileges = result.privileges || []
+ store.updateUserInfo({
+ userName: result.userName || formState.username,
+ role: roles[0],
+ roles,
+ privileges,
+ })
setTimeout(() => {
window.location.href = '/'
}, 100)
diff --git a/amoro-web/src/views/resource/components/List.vue
b/amoro-web/src/views/resource/components/List.vue
index 908c7193b..063c2db7c 100644
--- a/amoro-web/src/views/resource/components/List.vue
+++ b/amoro-web/src/views/resource/components/List.vue
@@ -24,6 +24,7 @@ import type { IIOptimizeGroupItem, IOptimizeResourceTableItem
} from '@/types/co
import { getOptimizerResourceList, getResourceGroupsListAPI, groupDeleteAPI,
groupDeleteCheckAPI, releaseResource } from '@/services/optimize.service'
import { usePagination } from '@/hooks/usePagination'
import { dateFormat, mbToSize } from '@/utils'
+import { canManageOptimizer } from '@/utils/permission'
const props = defineProps<{ curGroupName?: string, type: string }>()
@@ -47,6 +48,7 @@ const STATUS_CONFIG = shallowReactive({
const loading = ref<boolean>(false)
const releaseLoading = ref<boolean>(false)
+const writable = canManageOptimizer()
const tableColumns = shallowReactive([
{ dataIndex: 'name', title: t('name'), ellipsis: true },
{ dataIndex: 'container', title: t('container'), width: '23%', ellipsis:
true },
@@ -218,7 +220,7 @@ onMounted(() => {
<span>{{ record.optimizeStatus }}</span>
</template>
<template v-if="column.dataIndex === 'operation'">
- <span
+ <span v-if="writable"
class="primary-link" :class="{ disabled: record.container ===
'external' }"
@click="releaseModal(record)"
>
@@ -226,12 +228,14 @@ onMounted(() => {
</span>
</template>
<template v-if="column.dataIndex === 'operationGroup'">
+ <template v-if="writable">
<span class="primary-link g-mr-12" @click="editGroup(record)">
{{ t('edit') }}
</span>
<span class="primary-link" @click="removeGroup(record)">
{{ t('remove') }}
</span>
+ </template>
</template>
</template>
</a-table>
diff --git a/amoro-web/src/views/resource/index.vue
b/amoro-web/src/views/resource/index.vue
index a3fd96545..e3b742309 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 { canManageOptimizer } 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 = canManageOptimizer()
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' : '']"
>
- <a-button type="primary" class="g-mb-16"
@click="createOptimizer(null)">
+ <a-button v-if="writable" type="primary" class="g-mb-16"
@click="createOptimizer(null)">
{{ t("createOptimizer") }}
</a-button>
<List type="optimizers" />
@@ -168,7 +171,7 @@ export default defineComponent({
:tab="t('optimizerGroups')"
:class="[activeTab === 'optimizerGroups' ? 'active' : '']"
>
- <a-button type="primary" class="g-mb-16"
@click="editGroup(null)">
+ <a-button v-if="writable" type="primary" class="g-mb-16"
@click="editGroup(null)">
{{ t("addGroup") }}
</a-button>
<List
diff --git a/amoro-web/src/views/tables/components/Optimizing.vue
b/amoro-web/src/views/tables/components/Optimizing.vue
index c56398c7b..c7ccc0b50 100644
--- a/amoro-web/src/views/tables/components/Optimizing.vue
+++ b/amoro-web/src/views/tables/components/Optimizing.vue
@@ -25,6 +25,7 @@ import { usePagination } from '@/hooks/usePagination'
import type { BreadcrumbOptimizingItem, IColumns, ILableAndValue } from
'@/types/common.type'
import { cancelOptimizingProcess, getOptimizingProcesses,
getTableOptimizingTypes, getTasksByOptimizingProcessId } from
'@/services/table.service'
import { bytesToSize, dateFormat, formatMS2Time } from '@/utils/index'
+import { canManageTable } from '@/utils/permission'
const hasBreadcrumb = ref<boolean>(false)
@@ -77,6 +78,7 @@ const breadcrumbDataSource =
reactive<BreadcrumbOptimizingItem[]>([])
const loading = ref<boolean>(false)
const cancelDisabled = ref(true)
+const writable = ref<boolean>(canManageTable())
const pagination = reactive(usePagination())
const breadcrumbPagination = reactive(usePagination())
const route = useRoute()
@@ -328,7 +330,7 @@ onMounted(() => {
</a-breadcrumb>
</a-col>
<a-col :span="6">
- <a-button
+ <a-button v-if="writable"
v-model:disabled="cancelDisabled" type="primary" class="g-mb-16"
style="float: right"
@click="cancel"
>
diff --git a/amoro-web/src/views/terminal/index.vue
b/amoro-web/src/views/terminal/index.vue
index 4e696c56a..5fa4074b8 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 { canExecuteSql } from '@/utils/permission'
interface ISessionInfo {
sessionId: string
@@ -53,6 +54,7 @@ export default defineComponent({
const sqlLogRef = ref<any>(null)
const { pageScrollRef } = usePageScroll()
const readOnly = ref<boolean>(false)
+ const writable = ref<boolean>(canExecuteSql())
const sqlSource = ref<string>('')
const showDebug = ref<boolean>(false)
const runStatus = ref<string>('')
@@ -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({
</div>
<a-tooltip v-if="runStatus === 'Running'" :title="$t('pause')"
placement="bottom">
<svg-icon
- class-name="icon-svg" icon-class="sqlpause"
class="g-mr-12" :disabled="readOnly"
+ class-name="icon-svg" icon-class="sqlpause"
class="g-mr-12" :disabled="readOnly || !writable"
@click="handleIconClick('pause')"
/>
</a-tooltip>
<a-tooltip v-else :title="$t('run')" placement="bottom">
<svg-icon
- class-name="icon-svg" icon-class="sqldebug"
class="g-mr-12" :disabled="readOnly"
+ class-name="icon-svg" icon-class="sqldebug"
class="g-mr-12" :disabled="readOnly || !writable"
@click="handleIconClick('debug')"
/>
</a-tooltip>
@@ -411,7 +418,7 @@ export default defineComponent({
</div>
<a-button
v-for="code in shortcuts" :key="code" type="link"
- :disabled="runStatus === 'Running' || runStatus === 'Canceling'"
class="code" @click="generateCode(code)"
+ :disabled="runStatus === 'Running' || runStatus === 'Canceling'
|| !writable" class="code" @click="generateCode(code)"
>
{{
code
diff --git a/dev/deps/dependencies-hadoop-2-spark-3.3
b/dev/deps/dependencies-hadoop-2-spark-3.3
index cd86dfc6f..d8a476f6e 100644
--- a/dev/deps/dependencies-hadoop-2-spark-3.3
+++ b/dev/deps/dependencies-hadoop-2-spark-3.3
@@ -28,6 +28,7 @@ arrow-vector/15.0.2//arrow-vector-15.0.2.jar
asm/5.0.4//asm-5.0.4.jar
async-profiler/2.9//async-profiler-2.9.jar
auth/2.24.12//auth-2.24.12.jar
+aviator/5.9.0//aviator-5.9.0.jar
avro-ipc/1.11.0//avro-ipc-1.11.0.jar
avro-mapred/1.11.0//avro-mapred-1.11.0.jar
avro/1.11.3//avro-1.11.3.jar
@@ -53,11 +54,12 @@ commons-compiler/3.0.16//commons-compiler-3.0.16.jar
commons-compress/1.23.0//commons-compress-1.23.0.jar
commons-configuration/1.6//commons-configuration-1.6.jar
commons-crypto/1.1.0//commons-crypto-1.1.0.jar
+commons-csv/1.12.0//commons-csv-1.12.0.jar
commons-dbcp/1.4//commons-dbcp-1.4.jar
commons-dbcp2/2.9.0//commons-dbcp2-2.9.0.jar
commons-digester/1.8//commons-digester-1.8.jar
commons-el/1.0//commons-el-1.0.jar
-commons-io/2.11.0//commons-io-2.11.0.jar
+commons-io/2.20.0//commons-io-2.20.0.jar
commons-lang/2.6//commons-lang-2.6.jar
commons-lang3/3.20.0//commons-lang3-3.20.0.jar
commons-logging/1.2//commons-logging-1.2.jar
@@ -105,7 +107,7 @@
flink-shaded-netty/4.1.91.Final-17.0//flink-shaded-netty-4.1.91.Final-17.0.jar
flink-streaming-java/1.20.3//flink-streaming-java-1.20.3.jar
geronimo-jcache_1.0_spec/1.0-alpha-1//geronimo-jcache_1.0_spec-1.0-alpha-1.jar
glue/2.24.12//glue-2.24.12.jar
-gson/2.8.6//gson-2.8.6.jar
+gson/2.11.0//gson-2.11.0.jar
guava/32.1.1-jre//guava-32.1.1-jre.jar
hadoop-annotations/2.10.2//hadoop-annotations-2.10.2.jar
hadoop-auth/2.10.2//hadoop-auth-2.10.2.jar
@@ -162,6 +164,7 @@
iceberg-spark-3.3_2.12/1.6.1//iceberg-spark-3.3_2.12-1.6.1.jar
iceberg-spark-extensions-3.3_2.12/1.6.1//iceberg-spark-extensions-3.3_2.12-1.6.1.jar
icu4j/69.1//icu4j-69.1.jar
identity-spi/2.24.12//identity-spi-2.24.12.jar
+ipaddress/5.5.1//ipaddress-5.5.1.jar
ivy/2.5.1//ivy-2.5.1.jar
j2objc-annotations/2.8//j2objc-annotations-2.8.jar
jackson-annotations/2.14.2//jackson-annotations-2.14.2.jar
@@ -192,6 +195,7 @@ javolution/5.5.1//javolution-5.5.1.jar
jaxb-api/2.2.2//jaxb-api-2.2.2.jar
jboss-logging/3.3.1.Final//jboss-logging-3.3.1.Final.jar
jboss-threads/2.3.6.Final//jboss-threads-2.3.6.Final.jar
+jcasbin/1.99.0//jcasbin-1.99.0.jar
jcip-annotations/1.0-1//jcip-annotations-1.0-1.jar
jdo-api/3.0.1//jdo-api-3.0.1.jar
jersey-client/2.36//jersey-client-2.36.jar
diff --git a/dev/deps/dependencies-hadoop-3-spark-3.5
b/dev/deps/dependencies-hadoop-3-spark-3.5
index 2facef79f..59f2a1c2b 100644
--- a/dev/deps/dependencies-hadoop-3-spark-3.5
+++ b/dev/deps/dependencies-hadoop-3-spark-3.5
@@ -23,6 +23,7 @@ arrow-vector/15.0.2//arrow-vector-15.0.2.jar
asm/5.0.4//asm-5.0.4.jar
async-profiler/2.9//async-profiler-2.9.jar
auth/2.24.12//auth-2.24.12.jar
+aviator/5.9.0//aviator-5.9.0.jar
avro-ipc/1.11.4//avro-ipc-1.11.4.jar
avro-mapred/1.11.4//avro-mapred-1.11.4.jar
avro/1.11.3//avro-1.11.3.jar
@@ -47,9 +48,10 @@ commons-collections4/4.4//commons-collections4-4.4.jar
commons-compiler/3.1.9//commons-compiler-3.1.9.jar
commons-compress/1.23.0//commons-compress-1.23.0.jar
commons-crypto/1.1.0//commons-crypto-1.1.0.jar
+commons-csv/1.12.0//commons-csv-1.12.0.jar
commons-dbcp/1.4//commons-dbcp-1.4.jar
commons-dbcp2/2.9.0//commons-dbcp2-2.9.0.jar
-commons-io/2.16.1//commons-io-2.16.1.jar
+commons-io/2.20.0//commons-io-2.20.0.jar
commons-lang/2.6//commons-lang-2.6.jar
commons-lang3/3.20.0//commons-lang3-3.20.0.jar
commons-logging/1.2//commons-logging-1.2.jar
@@ -98,7 +100,7 @@
flink-shaded-jackson/2.14.2-17.0//flink-shaded-jackson-2.14.2-17.0.jar
flink-shaded-netty/4.1.91.Final-17.0//flink-shaded-netty-4.1.91.Final-17.0.jar
flink-streaming-java/1.20.3//flink-streaming-java-1.20.3.jar
glue/2.24.12//glue-2.24.12.jar
-gson/2.10.1//gson-2.10.1.jar
+gson/2.11.0//gson-2.11.0.jar
guava/32.1.1-jre//guava-32.1.1-jre.jar
hadoop-aws/3.4.2//hadoop-aws-3.4.2.jar
hadoop-client-api/3.4.2//hadoop-client-api-3.4.2.jar
@@ -145,6 +147,7 @@
iceberg-spark-3.5_2.12/1.6.1//iceberg-spark-3.5_2.12-1.6.1.jar
iceberg-spark-extensions-3.5_2.12/1.6.1//iceberg-spark-extensions-3.5_2.12-1.6.1.jar
icu4j/69.1//icu4j-69.1.jar
identity-spi/2.24.12//identity-spi-2.24.12.jar
+ipaddress/5.5.1//ipaddress-5.5.1.jar
ivy/2.5.1//ivy-2.5.1.jar
j2objc-annotations/2.8//j2objc-annotations-2.8.jar
jackson-annotations/2.14.2//jackson-annotations-2.14.2.jar
@@ -168,6 +171,7 @@ javax.servlet-api/3.1.0//javax.servlet-api-3.1.0.jar
javolution/5.5.1//javolution-5.5.1.jar
jboss-logging/3.3.1.Final//jboss-logging-3.3.1.Final.jar
jboss-threads/2.3.6.Final//jboss-threads-2.3.6.Final.jar
+jcasbin/1.99.0//jcasbin-1.99.0.jar
jdo-api/3.0.1//jdo-api-3.0.1.jar
jersey-client/2.40//jersey-client-2.40.jar
jersey-common/2.40//jersey-common-2.40.jar
diff --git a/dist/src/main/amoro-bin/conf/config.yaml
b/dist/src/main/amoro-bin/conf/config.yaml
index 884ce5896..27d3e5eb4 100644
--- a/dist/src/main/amoro-bin/conf/config.yaml
+++ b/dist/src/main/amoro-bin/conf/config.yaml
@@ -40,6 +40,36 @@ 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) ──
+ # Authorization enablement example:
+ # authorization:
+ # enabled: true
+ #
+ # LDAP Group role-mapping example:
+ # authorization:
+ # enabled: true
+ # ldap-role-mapping:
+ # enabled: true
+ # 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"
+ # groups:
+ # - group-dn: "cn=amoro-service-admins,ou=groups,dc=example,dc=com"
+ # role: SERVICE_ADMIN
+ # - group-dn: "cn=amoro-viewers,ou=groups,dc=example,dc=com"
+ # role: VIEWER
+ # - group-dn: "cn=amoro-catalog-admins,ou=groups,dc=example,dc=com"
+ # role: CATALOG_ADMIN
+ #
+ # Custom roles are string-based. To use a custom role such as
CATALOG_ADMIN,
+ # define its privileges in the built-in Casbin policy resource and map an
+ # LDAP group to that role name here.
+ #
+ # Optional fallback:
+ # set default-role: VIEWER only when you intentionally want authenticated
+ # users without a matched role mapping to receive read-only access.
refresh-external-catalogs:
interval: 3min # 180000
@@ -111,7 +141,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 ec4f34c39..c929ef2a8 100644
--- a/docs/configuration/ams-config.md
+++ b/docs/configuration/ams-config.md
@@ -87,6 +87,14 @@ 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.default-role | <undefined> | Optional
default dashboard role for authenticated users without an LDAP role mapping. |
+| http-server.authorization.enabled | false | Whether to enable dashboard RBAC
authorization. |
+| 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.groups | <undefined> |
LDAP group-to-role mapping entries containing group-dn and role fields. |
+| 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.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. |
@@ -135,6 +143,91 @@ 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.
+
+The current RBAC model uses:
+
+- string-based roles
+- LDAP group-to-role mapping as the primary role source
+- built-in Casbin policy to translate roles into privileges
+- privilege-driven frontend authorization
+
+Amoro provides two built-in roles by default:
+
+| Role | Description | Default Privileges |
+| --- | --- | --- |
+| `SERVICE_ADMIN` | Platform administrator | All privileges |
+| `VIEWER` | Read-only resource viewer | `VIEW_CATALOG`, `VIEW_TABLE`,
`VIEW_OPTIMIZER` |
+
+`VIEWER` does not include `VIEW_SYSTEM`, so it cannot access `Overview` or
`Terminal`.
+After login succeeds, `/login/current` returns both `roles` and effective
`privileges`.
+
+If you need additional roles, define them by Casbin policy and map LDAP groups
to those
+role names. The role name itself does not need to be added to Java enum code.
+
+```yaml
+ams:
+ http-server:
+ authorization:
+ enabled: true
+```
+
+```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
+ ldap-role-mapping:
+ enabled: true
+ 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"
+ groups:
+ - group-dn: "cn=amoro-service-admins,ou=groups,dc=example,dc=com"
+ role: SERVICE_ADMIN
+ - group-dn: "cn=amoro-viewers,ou=groups,dc=example,dc=com"
+ role: VIEWER
+ - group-dn: "cn=amoro-catalog-admins,ou=groups,dc=example,dc=com"
+ role: CATALOG_ADMIN
+```
+
+Example `/login/current` response:
+
+```json
+{
+ "userName": "alice",
+ "roles": ["CATALOG_ADMIN"],
+ "privileges": [
+ "VIEW_CATALOG",
+ "MANAGE_CATALOG",
+ "VIEW_TABLE",
+ "MANAGE_TABLE"
+ ]
+}
+```
+
+Example custom role policy:
+
+```csv
+p, CATALOG_ADMIN, CATALOG, GLOBAL, VIEW_CATALOG, allow
+p, CATALOG_ADMIN, CATALOG, GLOBAL, MANAGE_CATALOG, allow
+p, CATALOG_ADMIN, TABLE, GLOBAL, VIEW_TABLE, allow
+p, CATALOG_ADMIN, TABLE, GLOBAL, MANAGE_TABLE, allow
+```
+
+Notes:
+
+- Recommended production setup is explicit role assignment only.
+- `default-role` is optional. If it is not set, users who do not match any
role mapping get no business role.
+- Use `default-role: VIEWER` only if you intentionally want authenticated
users without a matched role mapping to receive read-only access.
+- Casbin model and default policy are built into the service and loaded from
classpath.
+- Dashboard request-to-privilege mapping is also built into the service and
loaded from a resource configuration file.
## Shade Utils Configuration
diff --git a/pom.xml b/pom.xml
index 070c9c0f1..506b88753 100644
--- a/pom.xml
+++ b/pom.xml
@@ -164,6 +164,7 @@
<hudi.version>0.14.1</hudi.version>
<pagehelper.version>6.1.0</pagehelper.version>
<jsqlparser.version>4.7</jsqlparser.version>
+ <jcasbin.version>1.99.0</jcasbin.version>
<fasterxml.jackson.version>2.14.2</fasterxml.jackson.version>
<apache-directory-server.version>2.0.0-M15</apache-directory-server.version>
<apache-directory-api-all.version>1.0.0-M20</apache-directory-api-all.version>