zhoujinsong commented on code in PR #4118: URL: https://github.com/apache/amoro/pull/4118#discussion_r2993236134
########## 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, Review Comment: **[Bug] `resolvePrivileges` and `authorize` use inconsistent privilege sources** `resolvePrivileges` reads from the pre-built `rolePrivileges` map (loaded from the CSV), while `authorize` uses the live Casbin enforcer. If a policy row carries `eft=deny`, `authorize` will correctly block the request, but `resolvePrivileges` will still include that privilege in the list returned to the frontend (because `loadRolePrivileges` only filters rows whose index-5 token is literally `"deny"`). Consider deriving privileges from the enforcer directly, e.g. `enforcer.getPermissionsForUser(role)`, so both paths stay in sync. ########## 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; + } Review Comment: **[Bug] LDAP group query fails hard on every login when one group is unreachable** If any single group DN throws a `NamingException`, the resolver re-throws a `RuntimeException`, which propagates all the way to `LoginController` and causes the login to fail — even though authentication already succeeded and the user might belong to other groups. Consider: (1) catching the exception per-group, logging a warning, and continuing to query the remaining groups; (2) or at least documenting that a partial LDAP outage will break all logins. The current test `testThrowsWhenLookupFails` validates the hard-fail behaviour, but that policy seems too strict for a background enrichment step. ########## 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"); Review Comment: **[Bug] Path-prefix matching can produce false positives** `prefixes.stream().anyMatch(path::startsWith)` will match `/api/ams/v1/catalogs-extra` against the prefix `/api/ams/v1/catalogs`, granting unintended privileges to paths that merely share a common string prefix. Add a separator boundary check: `path.startsWith(prefix + "/") || path.equals(prefix)`. ########## amoro-ams/src/main/java/org/apache/amoro/server/dashboard/DashboardServer.java: ########## @@ -408,8 +419,30 @@ public void preHandleRequest(Context ctx) { boolean isWebRequest = X_REQUEST_SOURCE_WEB.equalsIgnoreCase(requestSource); if (isWebRequest) { - if (null == ctx.sessionAttribute("user")) { - throw new ForbiddenException("User session attribute is missed for url: " + uriPath); + LoginController.SessionInfo user = ctx.sessionAttribute("user"); + if (user == null) { + throw new ForbiddenException(LOGIN_REQUIRED_MESSAGE); + } + if (authorizationManager.isAuthorizationEnabled()) { + AuthorizationRequest request = + privilegeMapper + .resolve(ctx) + .orElseThrow( + () -> + new ForbiddenException( + "No authorization mapping for request: " + + ctx.method() + + " " Review Comment: **[Bug] Unmapped request paths return 403 instead of 404** When `privilegeMapper.resolve(ctx)` returns `Optional.empty()` (i.e., the request path is not covered by `privilege_mapping.yaml`), the code throws `ForbiddenException` with the message `"No authorization mapping for request: ..."`. This means any new or typo-ed API endpoint silently returns 403 rather than 404, which is confusing to callers and leaks information about the authorization layer. A missing mapping means the resource doesn't exist or wasn't declared, not that the user lacks permission. Consider returning 404 for unmapped paths, or fall back to allowing the request (to avoid breaking undeclared internal endpoints). ########## amoro-ams/src/main/java/org/apache/amoro/server/dashboard/controller/LoginController.java: ########## @@ -59,15 +73,37 @@ public void login(Context ctx) { PreconditionUtils.checkNotNullOrEmpty(user, "user"); PreconditionUtils.checkNotNullOrEmpty(pwd, "password"); DefaultPasswordCredential credential = new DefaultPasswordCredential(user, pwd); + + // Step 1: Authenticate the user + Principal principal; try { - this.loginAuthProvider.authenticate(credential); - ctx.sessionAttribute("user", new SessionInfo(user, System.currentTimeMillis() + "")); - ctx.json(OkResponse.of("success")); + principal = this.loginAuthProvider.authenticate(credential); } catch (Exception e) { LOG.error("authenticate user {} failed", user, e); String causeMessage = e.getMessage() != null ? e.getMessage() : "unknown error"; throw new RuntimeException("invalid user " + user + " or password! Cause: " + causeMessage); } + + // Step 2: Resolve user role (LDAP group lookup) + String authenticatedUser = principal.getName(); Review Comment: **[Minor] LDAP group lookup happens on every login — consider caching** `roleResolver.resolve(authenticatedUser)` queries all configured LDAP groups on every single login. For environments with many groups or a slow LDAP server this adds noticeable latency to the login flow. A short-lived cache (e.g. Guava `LoadingCache` with a 5-minute TTL) keyed by username would avoid repeated round trips without meaningfully delaying role updates. ########## 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)) { Review Comment: **[Minor] Admin username comparison is case-sensitive** `adminUsername.equals(username)` — if `LdapPasswdAuthenticationProvider.normalizeUsername` strips the domain but the configured `admin-username` was set to `Admin` (capital A), the bootstrap admin will not get `SERVICE_ADMIN` role. Consider using `equalsIgnoreCase` or at least documenting the case-sensitivity requirement. ########## 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 Review Comment: **[Bug] `MANAGE_PLATFORM` mapping has no `methods` filter** The last mapping entry for `/api/ams/v1/files`, `/api/ams/v1/settings`, and `/api/ams/v1/api/token` omits the `methods` field. Per `DashboardPrivilegeMappingRule`, an empty method set means _all_ HTTP methods match. This causes GET requests to `/api/ams/v1/settings` to require `MANAGE_PLATFORM`, which is too strict — a read request should only need `VIEW_SYSTEM` or similar. Add explicit `methods` to distinguish read vs. write access. ########## 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[] +} Review Comment: **[Minor] `hasPrivilege` returns `true` for all paths when authorization is disabled** When RBAC is disabled the backend sets every user's privileges to the full `Privilege` enum set, so `hasPrivilege` will always return `true`. This is correct, but it means the frontend permission model silently relies on backend behaviour. If a future refactor changes the disabled-mode response, all frontend guards will break without a clear indication why. Consider adding a comment here explaining the contract: _"When authorization is disabled, the backend returns all privileges."_ ########## 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({ Review Comment: **[Minor] 403 "no permission" errors are silently swallowed** When a 403 response does _not_ contain the word `"login"` in the message, the code rejects the promise but the caller's `handleError` branch is never invoked (because the early return skips the error-display logic). This means write operations forbidden by RBAC will silently fail from the user's perspective. Suggest surfacing the `No permission` message to the user via `message.error(...)` before rejecting. ########## amoro-web/src/views/catalogs/index.vue: ########## @@ -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()) Review Comment: **[Minor] `writable` is a non-reactive plain `ref`, not a `computed`** `const writable = ref<boolean>(canManageCatalog())` captures the privilege value at component mount time. If the store's `privileges` array is updated after mount (e.g., after a re-login within the same SPA session), `writable` will not update. `Detail.vue` does this correctly with `computed(() => canManageCatalog())`. Please align `catalogs/index.vue` to use `computed` as well. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
