This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git
commit dad5f45d9691efbc34856992a9c0981bc6663d3d Author: dark <[email protected]> AuthorDate: Sun Aug 16 03:48:50 2026 +0800 feat(hubble): unify compatibility and auth modes - centralize server capability profiles in the client - derive authenticated and anonymous modes from the backend - expose readable GraphSpace permission presets --- README.md | 13 +- .../org/apache/hugegraph/driver/HugeClient.java | 3 +- .../hugegraph/driver/ServerCompatibility.java | 67 ++++ .../hugegraph/driver/ServerCompatibilityTest.java | 41 ++ hugegraph-hubble/AGENTS.md | 39 ++ hugegraph-hubble/README.md | 37 ++ .../docs/images/hubble-auth-connection-root.png | Bin 0 -> 56400 bytes .../docs/images/hubble-auth-connection.png | Bin 0 -> 41540 bytes .../docs/images/hubble-graphspace-permissions.png | Bin 0 -> 60284 bytes .../docs/images/hubble-legacy-capability.png | Bin 0 -> 45865 bytes hugegraph-hubble/docs/images/hubble-non-auth.png | Bin 0 -> 41540 bytes .../docs/images/hubble-pd-connection.png | Bin 0 -> 47441 bytes .../org/apache/hugegraph/config/WebMvcConfig.java | 1 + .../hugegraph/controller/BaseController.java | 12 +- .../hugegraph/controller/ConfigController.java | 1 + .../hugegraph/controller/auth/LoginController.java | 4 + .../apache/hugegraph/entity/auth/UserEntity.java | 7 + .../hugegraph/handler/CustomInterceptor.java | 34 +- .../apache/hugegraph/handler/LoginInterceptor.java | 8 + .../apache/hugegraph/options/HubbleOptions.java | 9 + .../hugegraph/service/auth/AuthContextService.java | 25 ++ .../hugegraph/service/auth/AuthModeService.java | 47 +++ .../service/auth/GraphSpaceUserService.java | 90 +++++ .../apache/hugegraph/service/auth/UserService.java | 89 +++++ hugegraph-hubble/hubble-fe/src/App.js | 40 ++ hugegraph-hubble/hubble-fe/src/auth/AuthContext.js | 10 +- .../hubble-fe/src/components/Topbar/index.ant.js | 39 +- .../src/i18n/resources/en-US/modules/pages.json | 15 +- .../src/i18n/resources/zh-CN/modules/pages.json | 15 +- .../hubble-fe/src/pages/Account/EditLayer.js | 96 +++-- .../hubble-fe/src/pages/Account/SpaceAccess.js | 439 +++------------------ .../src/pages/Account/SpaceAccess.test.js | 61 ++- .../pages/Account/account-edit-recovery.test.js | 6 +- .../src/pages/Account/account-recovery.test.js | 9 +- .../hubble-fe/src/pages/Account/index.js | 10 +- .../src/pages/Account/permissionPresets.js | 79 ++++ .../src/pages/Account/permissionPresets.test.js | 70 ++++ hugegraph-hubble/hubble-fe/src/routes/index.js | 8 +- hugegraph-hubble/hubble-fe/src/utils/config.js | 6 +- 39 files changed, 957 insertions(+), 473 deletions(-) diff --git a/README.md b/README.md index 02c1bb719..3eb7a6253 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,17 @@ A comprehensive suite of client SDKs, data tools, and management utilities for [Apache HugeGraph](https://github.com/apache/hugegraph) graph database. Build applications, load data, and manage graphs with production-ready tools. +Hubble's primary authentication and connection design targets HugeGraph +`1.8/master`: PD discovery supplies the server address, anonymous mode uses a +real unauthenticated client, and account/GraphSpace permissions are reduced to +four readable presets. A thin adapter keeps 1.7 usable and limits 1.5 to its +standalone core graph workflow; version checks are centralized rather than +spread across UI pages. + + + + + **Quick Navigation**: [Architecture](#architecture-overview) | [Quick Start](#quick-start) | [Modules](#module-overview) | [Build](#build--development) | [Docker](#docker) | [Related Projects](#related-projects) ## Related Projects @@ -57,7 +68,7 @@ graph TB CLIENT --> HUBBLE CLIENT --> TOOLS CLIENT --> SPARK - HUBBLE -.->|WIP: pd-client| PD + HUBBLE -.->|PD discovery UI| PD LOADER -.->|Sources| SRC["CSV | JSON | HDFS<br/>MySQL | Kafka"] SPARK -.->|I/O| SPK["Spark DataFrames"] diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/HugeClient.java b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/HugeClient.java index 3f26091f3..f02fd6146 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/HugeClient.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/HugeClient.java @@ -209,7 +209,8 @@ public class HugeClient implements Closeable { // 0.81 equals to the {latest_api_version} +10 VersionUtil.check(apiVersion, "0.38", "0.81", "hugegraph-api in server"); this.client.apiVersion(apiVersion); - boolean supportGs = VersionUtil.gte(this.version.getCoreVersion(), "1.7.0"); + boolean supportGs = ServerCompatibility.supportsGraphSpace( + this.version.getCoreVersion()); this.client.setSupportGs(supportGs); } diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/ServerCompatibility.java b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/ServerCompatibility.java new file mode 100644 index 000000000..ac975d661 --- /dev/null +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/ServerCompatibility.java @@ -0,0 +1,67 @@ +/* + * 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.hugegraph.driver; + +import org.apache.hugegraph.util.VersionUtil; + +/** + * Small compatibility boundary shared by Hubble and clients. + * + * <p>Version checks belong here so callers can express capabilities instead + * of branching on server versions in controllers or pages. Unknown versions + * deliberately use the conservative legacy profile.</p> + */ +public final class ServerCompatibility { + + private static final String GRAPHSPACE_MIN_VERSION = "1.7.0"; + + private ServerCompatibility() { + } + + public static Profile profile(String coreVersion) { + if (coreVersion == null || coreVersion.trim().isEmpty()) { + return Profile.LEGACY; + } + try { + String normalized = coreVersion.trim(); + return VersionUtil.gte(normalized, GRAPHSPACE_MIN_VERSION) ? + Profile.MODERN : Profile.LEGACY; + } catch (RuntimeException ignored) { + return Profile.LEGACY; + } + } + + public static boolean supportsGraphSpace(String coreVersion) { + return profile(coreVersion).supportsGraphSpace(); + } + + public enum Profile { + LEGACY(false), + MODERN(true); + + private final boolean graphSpace; + + Profile(boolean graphSpace) { + this.graphSpace = graphSpace; + } + + public boolean supportsGraphSpace() { + return this.graphSpace; + } + } +} diff --git a/hugegraph-client/src/test/java/org/apache/hugegraph/driver/ServerCompatibilityTest.java b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/ServerCompatibilityTest.java new file mode 100644 index 000000000..f3bccbf17 --- /dev/null +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/ServerCompatibilityTest.java @@ -0,0 +1,41 @@ +/* + * 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.hugegraph.driver; + +import org.junit.Assert; +import org.junit.Test; + +public class ServerCompatibilityTest { + + @Test + public void shouldKeepLegacyServersConservative() { + Assert.assertFalse(ServerCompatibility.supportsGraphSpace("1.5.0")); + Assert.assertFalse(ServerCompatibility.supportsGraphSpace("1.6.0")); + Assert.assertFalse(ServerCompatibility.supportsGraphSpace(null)); + Assert.assertFalse(ServerCompatibility.supportsGraphSpace("not-a-version")); + } + + @Test + public void shouldExposeGraphSpaceForModernServers() { + Assert.assertTrue(ServerCompatibility.supportsGraphSpace("1.7.0")); + Assert.assertTrue(ServerCompatibility.supportsGraphSpace(" 1.7.0 ")); + Assert.assertTrue(ServerCompatibility.supportsGraphSpace("1.8.0")); + Assert.assertEquals(ServerCompatibility.Profile.MODERN, + ServerCompatibility.profile("1.7.1")); + } +} diff --git a/hugegraph-hubble/AGENTS.md b/hugegraph-hubble/AGENTS.md new file mode 100644 index 000000000..9f732a344 --- /dev/null +++ b/hugegraph-hubble/AGENTS.md @@ -0,0 +1,39 @@ +# Hubble contributor guide + +## Authentication and connection boundary + +The `1.8/master` path is the source of truth. Backend configuration exposes +one `auth.enabled` switch and one connection resolver. The resolver chooses +either a direct server URL or an address discovered from PD; callers must not +reimplement `usePD` or infer connection state from page-local flags. In PD mode +the server address returned by discovery is authoritative, so a manual server +URL is not required. + +Use the unauthenticated HugeGraph client for anonymous mode. Do not manufacture +an empty token or an administrator session. Anonymous mode has no account +context and account/permission routes are hidden or rejected at the capability +boundary. + +## Compatibility policy + +Compatibility is intentionally one-way: + +- `1.8/master`: modern GraphSpace/auth contracts and the complete UI. +- `1.7`: thin fallback for the legacy response shape; keep the core workflow + usable without adding version branches to controllers or React pages. +- `1.5` standalone: core graph/schema/data operations only. GraphSpace + management is unsupported and should degrade with an explicit capability + response. Do not add a PD variant for 1.5. + +Version checks belong in the client compatibility adapter and connection +resolver. New code should consume capabilities, not compare literal versions. +When an old image cannot satisfy a capability, mark the test as `needs input` +or `skipped` with the exact image tag and reason. + +## Verification + +For UI changes, use Chrome to exercise login/non-auth mode, connection +switching, and account/GraphSpace visibility. Static inspection and unit tests +are not a substitute for this interaction check. Keep screenshots collected +from the running UI in the documentation assets referenced by +`README.md`. diff --git a/hugegraph-hubble/README.md b/hugegraph-hubble/README.md index cbc13ad04..5b3fb9955 100644 --- a/hugegraph-hubble/README.md +++ b/hugegraph-hubble/README.md @@ -7,6 +7,43 @@ hugegraph-hubble is a graph management and analysis platform that provides features: graph data load, schema management, graph relationship analysis, and graphical display. +## Authentication, connections, and compatibility + +Hubble uses one capability-driven connection boundary for `1.8/master`. +`auth.enabled=true` creates an authenticated session; when it is `false`, Hubble +uses an unauthenticated client and does not create a fake user. Account and +permission entry points are hidden in anonymous mode. Connection switching +always goes through the backend resolver. In PD mode, a valid server address +returned by discovery is sufficient; a manually configured server URL is not +required. + +The UI presents four stable permission meanings: super administrator, GraphSpace +read-only, GraphSpace read-write, and GraphSpace administrator. The last one +means member management plus read/write within that GraphSpace; low-level +`role`, `target`, `access`, and `belong` fields are not exposed. + +The compatibility boundary is deliberately small. Server 1.7 uses a thin +legacy-response fallback. Server 1.5 standalone is limited to core graph, +schema, data, and Gremlin operations; GraphSpace management is reported as +unsupported. Version checks stay in the client adapter/resolver rather than +being scattered through controllers or pages. See +[`AGENTS.md`](AGENTS.md) for the support matrix and verification rules. + +### UI reference captures + +The following captures are collected from Chrome against the running Hubble +application and document the supported states: + + + + + + + + + + + ## Local development feedback loop Run the frontend with third-party source-map noise disabled: diff --git a/hugegraph-hubble/docs/images/hubble-auth-connection-root.png b/hugegraph-hubble/docs/images/hubble-auth-connection-root.png new file mode 100644 index 000000000..966624185 Binary files /dev/null and b/hugegraph-hubble/docs/images/hubble-auth-connection-root.png differ diff --git a/hugegraph-hubble/docs/images/hubble-auth-connection.png b/hugegraph-hubble/docs/images/hubble-auth-connection.png new file mode 100644 index 000000000..f205e3621 Binary files /dev/null and b/hugegraph-hubble/docs/images/hubble-auth-connection.png differ diff --git a/hugegraph-hubble/docs/images/hubble-graphspace-permissions.png b/hugegraph-hubble/docs/images/hubble-graphspace-permissions.png new file mode 100644 index 000000000..06f4a0c3f Binary files /dev/null and b/hugegraph-hubble/docs/images/hubble-graphspace-permissions.png differ diff --git a/hugegraph-hubble/docs/images/hubble-legacy-capability.png b/hugegraph-hubble/docs/images/hubble-legacy-capability.png new file mode 100644 index 000000000..982b789dc Binary files /dev/null and b/hugegraph-hubble/docs/images/hubble-legacy-capability.png differ diff --git a/hugegraph-hubble/docs/images/hubble-non-auth.png b/hugegraph-hubble/docs/images/hubble-non-auth.png new file mode 100644 index 000000000..f205e3621 Binary files /dev/null and b/hugegraph-hubble/docs/images/hubble-non-auth.png differ diff --git a/hugegraph-hubble/docs/images/hubble-pd-connection.png b/hugegraph-hubble/docs/images/hubble-pd-connection.png new file mode 100644 index 000000000..8decb8bf6 Binary files /dev/null and b/hugegraph-hubble/docs/images/hubble-pd-connection.png differ diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/WebMvcConfig.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/WebMvcConfig.java index ec0fb77e8..27c96f601 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/WebMvcConfig.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/WebMvcConfig.java @@ -66,6 +66,7 @@ public class WebMvcConfig implements WebMvcConfigurer { registry.addInterceptor(this.loginInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/**/auth/login") + .excludePathPatterns("/api/**/config") .excludePathPatterns("/logout") .excludePathPatterns("/api/**/auth/logout"); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java index 807da39fc..db091fe14 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java @@ -26,6 +26,7 @@ import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.driver.factory.PDHugeClientFactory; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.service.auth.AuthModeService; import org.apache.commons.collections.CollectionUtils; import org.apache.hugegraph.config.HugeConfig; import org.springframework.beans.factory.annotation.Autowired; @@ -59,6 +60,8 @@ public abstract class BaseController { @Autowired protected UserService userService; + @Autowired + protected AuthModeService authMode; public static final String ORDER_ASC = "asc"; public static final String ORDER_DESC = "desc"; @@ -143,8 +146,13 @@ public abstract class BaseController { client.assignGraph(graphSpace, graph); return client; } - HugeClient client = this.hugeClientPoolService.createAuthClient( - graphSpace, graph, this.getToken()); + HugeClient client = this.authMode.anonymous() ? + this.hugeClientPoolService.createUnauthClient() : + this.hugeClientPoolService.createAuthClient( + graphSpace, graph, this.getToken()); + if (graphSpace != null || graph != null) { + client.assignGraph(graphSpace, graph); + } request.setAttribute("hugeClient", client); return client; } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java index 5b3795d8a..7815e82e8 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java @@ -41,6 +41,7 @@ public class ConfigController { Map<String, Object> result = new HashMap<>(); boolean pdEnabled = config.get(HubbleOptions.PD_ENABLED); result.put("pd_enabled", pdEnabled); + result.put("auth_enabled", config.get(HubbleOptions.AUTH_ENABLED)); if (!pdEnabled) { result.put("server_url", config.get(HubbleOptions.SERVER_URL)); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java index b59b24b74..3400d066b 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java @@ -75,6 +75,10 @@ public class LoginController extends BaseController { @PostMapping("/login") public Object login(@RequestBody Login login) { + if (this.authMode.anonymous()) { + throw new ExternalException(HttpStatus.FORBIDDEN.value(), + "Authentication is disabled"); + } String address = this.getRequest().getRemoteAddr(); boolean pdEnabled = this.config.get(HubbleOptions.PD_ENABLED); this.loginAttemptGuard.checkAllowed(login.name(), address); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java index 9e3712819..f7953c5d3 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java @@ -30,6 +30,7 @@ import org.apache.hugegraph.common.Identifiable; import java.util.Date; import java.util.List; +import java.util.Map; @Data @NoArgsConstructor @@ -75,6 +76,12 @@ public class UserEntity implements Identifiable { @JsonProperty("resSpaces") protected List<String> resSpaces; + @JsonProperty("permission_preset") + private String permissionPreset; + + @JsonProperty("graphspace_permissions") + private List<Map<String, String>> graphspacePermissions; + @JsonProperty("spacenum") protected Integer spacenum; diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/CustomInterceptor.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/CustomInterceptor.java index b48d9cdbb..2bdc11f5a 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/CustomInterceptor.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/CustomInterceptor.java @@ -26,6 +26,7 @@ import javax.servlet.http.HttpSession; //import org.apache.hugegraph.license.LicenseVerifier; // TODO C Remove Licence import org.apache.hugegraph.service.HugeClientPoolService; +import org.apache.hugegraph.service.auth.AuthModeService; //import org.apache.hugegraph.service.license.LicenseService;// TODO C Remove Licence import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -47,6 +48,8 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { //private LicenseService licenseService;// TODO C Remove Licence @Autowired protected HugeClientPoolService hugeClientPoolService; + @Autowired + protected AuthModeService authMode; private static final Pattern CHECK_API_PATTERN = Pattern.compile(".*/graph-connections/\\d+/.+"); @@ -119,23 +122,26 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { if (this.isLogoutRequest(uri)) { return; } - if (!this.hasAuthSession(request)) { + if (this.authMode.anonymous()) { + client = unauthClient(); + } else if (!this.hasAuthSession(request)) { return; - } - String token = - (String) request.getSession().getAttribute(Constant.TOKEN_KEY); - String [] res = uri.split("/"); - String graphSpace = null; - String graph = null; - for (int i = 0; i < res.length; i++) { - if ("graphspaces".equals(res[i]) && i < res.length - 1) { - graphSpace = res[i + 1]; - } - if ("graphs".equals(res[i]) && i < res.length - 1) { - graph = res[i + 1]; + } else { + String token = + (String) request.getSession().getAttribute(Constant.TOKEN_KEY); + String [] res = uri.split("/"); + String graphSpace = null; + String graph = null; + for (int i = 0; i < res.length; i++) { + if ("graphspaces".equals(res[i]) && i < res.length - 1) { + graphSpace = res[i + 1]; + } + if ("graphs".equals(res[i]) && i < res.length - 1) { + graph = res[i + 1]; + } } + client = this.authClient(graphSpace, graph, token); } - client = this.authClient(graphSpace, graph, token); } request.setAttribute("hugeClient", client); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java index b0c1dd1ed..779c65c62 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java @@ -20,6 +20,8 @@ package org.apache.hugegraph.handler; import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.exception.UnauthorizedException; +import org.apache.hugegraph.service.auth.AuthModeService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.StringUtils; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; @@ -28,6 +30,9 @@ import javax.servlet.http.HttpServletResponse; public class LoginInterceptor extends HandlerInterceptorAdapter { + @Autowired + private AuthModeService authMode; + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, @@ -35,6 +40,9 @@ public class LoginInterceptor extends HandlerInterceptorAdapter { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } + if (this.authMode != null && this.authMode.anonymous()) { + return true; + } if (!this.hasTextSessionAttribute(request, Constant.TOKEN_KEY) || !this.hasTextSessionAttribute(request, Constant.USERNAME_KEY)) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java index c04132c32..7cb589d1a 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java @@ -279,6 +279,15 @@ public class HubbleOptions extends OptionHolder { true ); + public static final ConfigOption<Boolean> AUTH_ENABLED = + new ConfigOption<>( + "auth.enabled", + "Whether Hubble requires a user session. Set false when " + + "the connected HugeGraph Server runs in anonymous mode.", + null, + true + ); + public static final ConfigOption<String> SERVER_URL = new ConfigOption<>( "server.direct_url", diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java index dca1c4beb..b141b55de 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java @@ -87,6 +87,9 @@ public class AuthContextService { } public Map<String, Object> context(HugeClient client, String username) { + if (!this.config.get(HubbleOptions.AUTH_ENABLED)) { + return anonymousContext(this.config.get(HubbleOptions.PD_ENABLED)); + } boolean pdEnabled = this.config.get(HubbleOptions.PD_ENABLED); String mode = pdEnabled ? "PD" : "NON_PD"; String role; @@ -125,6 +128,28 @@ public class AuthContextService { return Collections.unmodifiableMap(context); } + private static Map<String, Object> anonymousContext(boolean pdEnabled) { + Map<String, Object> context = new LinkedHashMap<>(); + context.put("schema_version", SCHEMA_VERSION); + context.put("context_version", "anonymous-v1"); + context.put("mode", "NON_AUTH"); + context.put("username", null); + context.put("role", "ANONYMOUS"); + Map<String, Set<String>> actions = new LinkedHashMap<>(); + actions.put("graphspaces", pdEnabled ? + Collections.singleton("read") : Collections.emptySet()); + context.put("capabilities", pdEnabled ? + set(GRAPH_RESOURCES_ACCESS, GRAPHSPACES_READ) : + Collections.singleton(GRAPH_RESOURCES_ACCESS)); + context.put("actions", actions); + Map<String, Object> scopes = new LinkedHashMap<>(); + scopes.put("all_graphspaces", pdEnabled); + scopes.put("admin_graphspaces", Collections.emptyList()); + scopes.put("graph_resources", "SERVER_ANONYMOUS"); + context.put("scopes", scopes); + return Collections.unmodifiableMap(context); + } + private Set<String> capabilities(boolean pdEnabled, String role) { Set<String> capabilities = new LinkedHashSet<>(); capabilities.add(ACCOUNT_SELF_MANAGE); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthModeService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthModeService.java new file mode 100644 index 000000000..9500b709d --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthModeService.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.hugegraph.service.auth; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.options.HubbleOptions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * Single boundary for Hubble authentication mode. Business controllers should + * not infer mode from sessions or PD settings. + */ +@Service +public final class AuthModeService { + + private final HugeConfig config; + + @Autowired + public AuthModeService(HugeConfig config) { + this.config = config; + } + + public boolean enabled() { + return this.config.get(HubbleOptions.AUTH_ENABLED); + } + + public boolean anonymous() { + return !this.enabled(); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java index cfdd501cf..ad8a669fa 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java @@ -20,7 +20,10 @@ package org.apache.hugegraph.service.auth; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -32,6 +35,7 @@ import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.BelongEntity; import org.apache.hugegraph.entity.auth.RoleEntity; import org.apache.hugegraph.entity.auth.UserView; +import org.apache.hugegraph.exception.ExternalException; import org.apache.hugegraph.structure.auth.User; import org.apache.hugegraph.util.E; import org.apache.hugegraph.util.PageUtil; @@ -44,6 +48,8 @@ public class GraphSpaceUserService extends AuthService { @Autowired private BelongService belongService; + @Autowired + private RoleService roleService; public List<UserView> listUsers(HugeClient client, String graphSpace) { List<UserView> users = new ArrayList<>(); @@ -135,6 +141,90 @@ public class GraphSpaceUserService extends AuthService { return this.getUser(client, graphSpace, userView.getId()); } + public void applyPermissionPresets(HugeClient client, String username, + List<Map<String, String>> permissions, + String preset) { + if (preset == null || "SUPER_ADMIN".equals(preset)) { + return; + } + User account = client.auth().getUserByName(username); + if (account == null) { + return; + } + Map<String, String> desired = new HashMap<>(); + List<Map<String, String>> requested = permissions == null ? + new ArrayList<>() : permissions; + for (Map<String, String> permission : requested) { + String graphSpace = permission.get("graphspace"); + String permissionPreset = permission.get("permission_preset"); + if (graphSpace != null) { + desired.put(graphSpace, + "GS_READ_ONLY".equals(permissionPreset) ? + "observer" : "GS_READ_WRITE".equals(permissionPreset) ? + "analyst" : null); + } + } + for (String graphSpace : client.graphSpace().listGraphSpace()) { + UserView current = this.getUser(client, graphSpace, + account.id().toString()); + if (!desired.containsKey(graphSpace)) { + if (!current.getRoles().isEmpty() && + current.getRoles().stream().allMatch( + GraphSpaceUserService::isPresetRole)) { + this.unauthUser(client, graphSpace, + account.id().toString()); + } + continue; + } + String presetRole = desired.get(graphSpace); + if (presetRole == null) { + List<RoleEntity> customRoles = current.getRoles().stream() + .filter(existing -> !isPresetRole(existing)) + .collect(Collectors.toList()); + if (customRoles.isEmpty() && !current.getRoles().isEmpty()) { + this.unauthUser(client, graphSpace, + account.id().toString()); + } else if (!customRoles.isEmpty()) { + UserView view = new UserView(account.id().toString(), + username, customRoles); + this.createOrUpdate(client, graphSpace, view); + } + continue; + } + String role = this.resolvePresetRole(client, graphSpace, + presetRole); + UserView view = new UserView(account.id().toString(), username, + new ArrayList<>()); + view.addRole(new RoleEntity(role, role)); + current.getRoles().stream() + .filter(existing -> !isPresetRole(existing)) + .forEach(view::addRole); + this.createOrUpdate(client, graphSpace, view); + } + } + + private String resolvePresetRole(HugeClient client, String graphSpace, + String roleName) { + return this.roleService.list(client, graphSpace, true).stream() + .filter(role -> roleName.equalsIgnoreCase(role.name()) || + roleName.equalsIgnoreCase(role.nickname())) + .map(role -> role.id().toString()) + .findFirst() + .orElseThrow(() -> new ExternalException( + "auth.role.not-exist", roleName)); + } + + private static boolean isPresetRole(RoleEntity role) { + String name = role.getName() == null ? role.getId() : + role.getName(); + if (name == null) { + return false; + } + String normalized = name.toLowerCase(Locale.ROOT); + return "observer".equals(normalized) || + "analyst".equals(normalized); + } + public void unauthUser(HugeClient client, String graphSpace, String userId) { User account = client.auth().getUser(userId); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java index 035ba359b..6e696705f 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java @@ -22,11 +22,14 @@ import java.io.File; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Comparator; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Locale; import java.util.stream.Collectors; +import com.baomidou.mybatisplus.core.metadata.IPage; import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.common.Response; import org.apache.hugegraph.structure.auth.Login; @@ -39,6 +42,8 @@ import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.driver.AuthManager; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.entity.auth.RoleEntity; +import org.apache.hugegraph.entity.auth.UserView; import org.apache.hugegraph.exception.InternalException; import org.apache.hugegraph.structure.auth.User; import org.apache.hugegraph.util.HubbleUtil; @@ -62,6 +67,8 @@ public class UserService extends AuthService { @Autowired private HugeConfig config; + @Autowired + private GraphSpaceUserService graphSpaceUserService; private boolean isPdEnabled() { return config.get(HubbleOptions.PD_ENABLED); @@ -91,6 +98,7 @@ public class UserService extends AuthService { user.setSpacenum(countMap.get(user.getName())); user.setAdminSpaces(spaceMap.get(user.getName())); } + this.populatePermissionPresets(hugeClient, ues); } return ues; @@ -125,6 +133,9 @@ public class UserService extends AuthService { user.setAdminSpaces(spaceMap.get(user.getName())); user.setSuperadmin(isSuperAdmin(hugeClient, user.getId())); } + IPage<UserEntity> page = PageUtil.page(results, pageNo, pageSize); + this.populatePermissionPresets(hugeClient, page.getRecords()); + return page; } else { for (UserEntity user : results) { user.setSuperadmin(isStandaloneAdmin(user.getName())); @@ -158,6 +169,7 @@ public class UserService extends AuthService { userEntity.setAdminSpaces(adminSpaces); userEntity.setSpacenum(adminSpaces.size()); userEntity.setResSpaces(resSpaces); + this.populatePermissionPresets(hugeClient, userEntity); } else { userEntity.setSuperadmin(isStandaloneAdmin(user.name())); userEntity.setAdminSpaces(new ArrayList<>()); @@ -192,6 +204,7 @@ public class UserService extends AuthService { userEntity.setAdminSpaces(adminSpaces); userEntity.setSpacenum(adminSpaces.size()); userEntity.setResSpaces(resSpaces); + this.populatePermissionPresets(hugeClient, userEntity); } else { userEntity.setSuperadmin(isStandaloneAdmin(username)); userEntity.setAdminSpaces(new ArrayList<>()); @@ -219,6 +232,11 @@ public class UserService extends AuthService { client.auth().addSpaceAdmin(ue.getName(), graphspace); } } + if (isPdEnabled()) { + this.graphSpaceUserService.applyPermissionPresets( + client, ue.getName(), ue.getGraphspacePermissions(), + ue.getPermissionPreset()); + } if (newUser != null && ue.isSuperadmin()) { // add superadmin @@ -343,6 +361,71 @@ public class UserService extends AuthService { return u; } + private void populatePermissionPresets(HugeClient client, + UserEntity userEntity) { + List<Map<String, String>> permissions = new ArrayList<>(); + List<String> graphSpaces = client.graphSpace().listGraphSpace(); + for (String graphSpace : graphSpaces) { + if (userEntity.getAdminSpaces() != null && + userEntity.getAdminSpaces().contains(graphSpace)) { + permissions.add(permission(graphSpace, "GS_ADMIN")); + continue; + } + UserView view = this.graphSpaceUserService.getUser( + client, graphSpace, userEntity.getId()); + view.getRoles().forEach(role -> { + String name = role.getName() == null ? "" : + role.getName().toLowerCase(Locale.ROOT); + if ("observer".equals(name)) { + permissions.add(permission(graphSpace, "GS_READ_ONLY")); + } else if ("analyst".equals(name)) { + permissions.add(permission(graphSpace, "GS_READ_WRITE")); + } + }); + } + userEntity.setGraphspacePermissions(permissions); + } + + private void populatePermissionPresets(HugeClient client, + Collection<UserEntity> users) { + Map<String, List<Map<String, String>>> permissions = new HashMap<>(); + for (String graphSpace : client.graphSpace().listGraphSpace()) { + for (UserView view : this.graphSpaceUserService.listUsers( + client, graphSpace)) { + for (RoleEntity role : view.getRoles()) { + String name = role.getName() == null ? "" : + role.getName().toLowerCase(Locale.ROOT); + String preset = "observer".equals(name) ? + "GS_READ_ONLY" : "analyst".equals(name) ? + "GS_READ_WRITE" : null; + if (preset != null) { + permissions.computeIfAbsent(view.getId(), + key -> new ArrayList<>()).add( + permission(graphSpace, preset)); + } + } + } + } + for (UserEntity user : users) { + List<Map<String, String>> values = permissions.getOrDefault( + user.getId(), new ArrayList<>()); + if (user.getAdminSpaces() != null) { + for (String graphSpace : user.getAdminSpaces()) { + values.add(permission(graphSpace, "GS_ADMIN")); + } + } + user.setGraphspacePermissions(values); + } + } + + private static Map<String, String> permission(String graphSpace, + String preset) { + Map<String, String> permission = new HashMap<>(); + permission.put("graphspace", graphSpace); + permission.put("permission_preset", preset); + return permission; + } + protected List<Object> getSpaceAndSpacenum(HugeClient hugeClient) { AuthManager auth = hugeClient.auth(); List<Object> listMap = new ArrayList<>(); @@ -398,6 +481,12 @@ public class UserService extends AuthService { } hugeClient.auth().updateUser(user); + if (isPdEnabled()) { + this.graphSpaceUserService.applyPermissionPresets( + hugeClient, userEntity.getName(), + userEntity.getGraphspacePermissions(), + userEntity.getPermissionPreset()); + } } public void updatePersonal(HugeClient hugeClient, String username, diff --git a/hugegraph-hubble/hubble-fe/src/App.js b/hugegraph-hubble/hubble-fe/src/App.js index cf669d538..ec6ee6aa8 100644 --- a/hugegraph-hubble/hubble-fe/src/App.js +++ b/hugegraph-hubble/hubble-fe/src/App.js @@ -23,9 +23,49 @@ import './App.css'; import './styles/workbench.scss'; import Layout from './layout.ant'; import {AuthContextProvider} from './auth/AuthContext'; +import * as api from './api'; +import {setConfig} from './utils/config'; +import {useEffect, useState} from 'react'; function App() { + const [configReady, setConfigReady] = useState(false); + const [configError, setConfigError] = useState(false); + useEffect(() => { + let active = true; + api.config.getConfig() + .then(response => { + if (response?.status !== 200 || !response.data) { + throw new Error('invalid_hubble_config'); + } + if (active) { + setConfig(response.data); + setConfigReady(true); + } + }) + .catch(() => { + if (active) { + setConfigError(true); + } + }); + return () => { + active = false; + }; + }, []); + + if (configError) { + return ( + <div role='alert'> + Unable to load Hubble configuration. + <button type='button' onClick={() => window.location.reload()}> + Retry + </button> + </div> + ); + } + if (!configReady) { + return null; + } return ( <div> <AuthContextProvider> diff --git a/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js b/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js index 1d9bc0a8d..6b553e7da 100644 --- a/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js +++ b/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js @@ -29,6 +29,7 @@ import {useLocation} from 'react-router-dom'; import * as api from '../api/index'; import {AUTH_REVALIDATE_EVENT} from '../utils/authEvents'; import {getUser, USER_CHANGE_EVENT} from '../utils/user'; +import {isAuthEnabled} from '../utils/config'; const REFRESH_INTERVAL_MS = 60_000; const MIN_REFRESH_INTERVAL_MS = 5_000; @@ -41,6 +42,7 @@ const AuthContext = createContext({ }); const isSignedIn = () => Boolean(getUser()?.id); +const shouldLoadContext = () => isSignedIn() || !isAuthEnabled(); const unwrapContext = response => { if (response?.status !== 200 || !response.data @@ -60,11 +62,11 @@ const AuthContextProvider = ({children}) => { const lastSuccessRef = useRef(0); const [identityEpoch, setIdentityEpoch] = useState(0); const [state, setState] = useState(() => ( - isSignedIn() ? {...emptyState, loading: true} : emptyState + shouldLoadContext() ? {...emptyState, loading: true} : emptyState )); const load = useCallback(({force = false} = {}) => { - if (!isSignedIn()) { + if (!shouldLoadContext()) { setState(emptyState); return Promise.resolve(null); } @@ -87,7 +89,7 @@ const AuthContextProvider = ({children}) => { .then(context => { if (epoch === epochRef.current && requestId === latestRequestRef.current - && isSignedIn()) { + && shouldLoadContext()) { lastSuccessRef.current = Date.now(); setState({loading: false, context, error: null}); } @@ -116,7 +118,7 @@ const AuthContextProvider = ({children}) => { inFlightRef.current = null; lastSuccessRef.current = 0; setIdentityEpoch(epochRef.current); - if (isSignedIn()) { + if (shouldLoadContext()) { load({force: true}).catch(() => undefined); } else { diff --git a/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js b/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js index 32cc7754e..0f095e0bd 100644 --- a/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js +++ b/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js @@ -32,6 +32,7 @@ import { clearPersistedAlgorithmFormsForUser, } from '../../modules/algorithm/algorithmsForm/algorithmFormPersistence'; import {useAuthContext} from '../../auth/AuthContext'; +import {isAuthEnabled} from '../../utils/config'; const Topbar = () => { const userInfo = user.getUser(); @@ -50,7 +51,7 @@ const Topbar = () => { useEffect(() => { let cancelled = false; - if (!userInfo || !userInfo.id) { + if (isAuthEnabled() && (!userInfo || !userInfo.id)) { return undefined; } @@ -69,7 +70,7 @@ const Topbar = () => { }; }, [redirectToLogin, userInfo]); - if (!userInfo || !userInfo.id) { + if (isAuthEnabled() && (!userInfo || !userInfo.id)) { redirectToLogin(); } @@ -138,24 +139,26 @@ const Topbar = () => { title={t('workbench.shortcuts.open_button')} onClick={showShortcutHelp} /> - <Dropdown menu={userMenu} trigger={['click']}> - <Button - type='text' - className={`${style.right} ${style.userMenuTrigger}`} - aria-label={t('Topbar.user_menu', {name: userLabel})} - aria-haspopup='menu' - title={userLabel} - > - <Avatar - size={'small'} - icon={avatarLabel ? undefined : <UserOutlined />} - aria-label={userLabel} + {isAuthEnabled() && userInfo?.id && ( + <Dropdown menu={userMenu} trigger={['click']}> + <Button + type='text' + className={`${style.right} ${style.userMenuTrigger}`} + aria-label={t('Topbar.user_menu', {name: userLabel})} + aria-haspopup='menu' title={userLabel} > - {avatarLabel} - </Avatar> - </Button> - </Dropdown> + <Avatar + size={'small'} + icon={avatarLabel ? undefined : <UserOutlined />} + aria-label={userLabel} + title={userLabel} + > + {avatarLabel} + </Avatar> + </Button> + </Dropdown> + )} </div> </Layout.Header> ); diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json index 3c9f0fe08..e8b92359a 100644 --- a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json +++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json @@ -387,6 +387,12 @@ "SPACEADMIN": "Space Administrator", "USER": "Regular User" }, + "permission_preset": { + "SUPER_ADMIN": "Super Administrator", + "GS_READ_ONLY": "GraphSpace Read-only", + "GS_READ_WRITE": "GraphSpace Read-write", + "GS_ADMIN": "GraphSpace Administrator" + }, "form": { "title_detail": "View Account", "title_edit": "Edit Account", @@ -408,7 +414,11 @@ "default_password_help": "The initial password for a new account. The user should change it after signing in", "default_password_placeholder": "Enter a 5–16 character initial password", "permission": "Admin Permission", - "permission_help": "Select the GraphSpaces this account may administer. HugeGraph roles control ordinary access" + "permission_help": "Select the GraphSpaces this account may administer. HugeGraph roles control ordinary access", + "permission_preset": "Access preset", + "permission_preset_help": "Choose one clear access preset; low-level roles and targets stay internal", + "graphspaces": "GraphSpaces", + "graphspaces_help": "Select the GraphSpaces covered by this preset" }, "space_access": { "global_tab": "Global Accounts", @@ -428,7 +438,8 @@ "id": "Account ID", "name": "Account Name", "roles": "Roles", - "remove_confirm": "Remove this member from the GraphSpace?" + "remove_confirm": "Remove this member from the GraphSpace?", + "preset_unavailable": "The selected permission preset is not available on this GraphSpace." }, "role": { "add": "Create Role", diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json index 93a5ae2a8..da3e7f7e0 100644 --- a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json +++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json @@ -387,6 +387,12 @@ "SPACEADMIN": "空间管理员", "USER": "普通用户" }, + "permission_preset": { + "SUPER_ADMIN": "超级管理员", + "GS_READ_ONLY": "GraphSpace 只读", + "GS_READ_WRITE": "GraphSpace 读写", + "GS_ADMIN": "GraphSpace 管理员" + }, "form": { "title_detail": "查看账号", "title_edit": "编辑账号", @@ -408,7 +414,11 @@ "default_password_help": "创建账号时设置的初始密码;用户登录后应尽快修改", "default_password_placeholder": "请输入 5–16 位初始密码", "permission": "管理权限", - "permission_help": "选择该账号可以管理的图空间;普通访问权限由 HugeGraph 角色控制" + "permission_help": "选择该账号可以管理的图空间;普通访问权限由 HugeGraph 角色控制", + "permission_preset": "权限预设", + "permission_preset_help": "选择清晰的预设语义,底层角色与资源目标由系统维护", + "graphspaces": "GraphSpace", + "graphspaces_help": "选择该预设覆盖的 GraphSpace" }, "space_access": { "global_tab": "全局账号", @@ -428,7 +438,8 @@ "id": "账号 ID", "name": "账号名", "roles": "角色", - "remove_confirm": "确定从该 GraphSpace 移除此成员吗?" + "remove_confirm": "确定从该 GraphSpace 移除此成员吗?", + "preset_unavailable": "当前 GraphSpace 不支持所选权限预设。" }, "role": { "add": "创建角色", diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js index 4fceadc32..8d5d43292 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js @@ -16,17 +16,25 @@ * under the License. */ -import {Modal, Input, Form, Select, message, Spin, Switch} from 'antd'; +import {Modal, Input, Form, Select, message, Spin} from 'antd'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useTranslation} from 'react-i18next'; import * as api from '../../api'; import * as rules from '../../utils/rules'; import style from './index.module.scss'; import FormHelpLabel from '../../components/FormHelpLabel'; -import {getAccountLevel} from './level'; +import { + getAccountPreset, + getPresetSpaces, + PERMISSION_PRESETS, + toPermissionPayload, +} from './permissionPresets'; const PAGE_ERROR_CONFIG = {suppressBusinessErrorToast: true}; const DEFAULT_ALLOWED_OPERATIONS = {create: true, edit: true, auth: true}; +const permissionPresetChanged = (prev, next) => ( + prev.permission_preset !== next.permission_preset +); const HelpLabel = ({t, labelKey}) => ( <FormHelpLabel @@ -60,7 +68,7 @@ const EditLayer = ({ }; const createUser = useCallback(values => { - return api.auth.addUser(values, PAGE_ERROR_CONFIG).then(res => { + return api.auth.addUser(toPermissionPayload(values), PAGE_ERROR_CONFIG).then(res => { if (res.status === 200) { message.success(t('common.msg.create_success')); onCancel(); @@ -71,7 +79,9 @@ const EditLayer = ({ }).catch(() => message.error(t('common.msg.operation_failed'))); }, [onCancel, refresh, t]); const updateUser = useCallback(values => { - return api.auth.updateUser(data.id, values, PAGE_ERROR_CONFIG).then(res => { + return api.auth.updateUser( + data.id, toPermissionPayload(values), PAGE_ERROR_CONFIG + ).then(res => { if (res.status === 200) { message.success(t('common.msg.update_success')); onCancel(); @@ -85,7 +95,13 @@ const EditLayer = ({ }, [onCancel, refresh, data.id, t]); const updateUserAuth = useCallback(values => { - return api.auth.updateAdminspace(data.id, values.adminSpaces, PAGE_ERROR_CONFIG).then(res => { + const payload = toPermissionPayload({ + ...values, + permission_preset: PERMISSION_PRESETS.GS_ADMIN, + }); + return api.auth.updateAdminspace( + data.id, payload.adminSpaces, PAGE_ERROR_CONFIG + ).then(res => { if (res.status === 200) { message.success(t('common.msg.set_success')); onCancel(); @@ -176,7 +192,11 @@ const EditLayer = ({ if (res.status === 200) { if (op !== 'detail') { - form.setFieldsValue(res.data); + form.setFieldsValue({ + ...res.data, + permission_preset: getAccountPreset(res.data), + graphspaces: getPresetSpaces(res.data), + }); } setDetail(res.data); return; @@ -233,17 +253,14 @@ const EditLayer = ({ <Form.Item label={t('account.form.name')} className={style.item}> {detail.user_nickname} </Form.Item> - <Form.Item label={t('account.form.is_superadmin')} className={style.item}> - {detail.is_superadmin ? t('common.yes') : t('common.no')} - </Form.Item> - <Form.Item label={t('account.form.level')} className={style.item}> - {t(`account.level.${getAccountLevel(detail)}`)} + <Form.Item label={t('account.form.permission_preset')} className={style.item}> + {t(`account.permission_preset.${getAccountPreset(detail)}`)} </Form.Item> <Form.Item label={t('account.form.remark')} className={style.item}> {detail.user_description} </Form.Item> - <Form.Item label={t('account.form.permission')} className={style.item}> - {detail.adminSpaces ? detail.adminSpaces.join(',') : ''} + <Form.Item label={t('account.form.graphspaces')} className={style.item}> + {getPresetSpaces(detail).join(', ')} </Form.Item> <Form.Item label={t('account.col.create_time')} className={style.item}> {detail.user_create} @@ -291,11 +308,16 @@ const EditLayer = ({ <Input placeholder={t('account.form.name_placeholder')} /> </Form.Item> <Form.Item - label={<HelpLabel t={t} labelKey='account.form.is_superadmin' />} - name="is_superadmin" - valuePropName="checked" + label={<HelpLabel t={t} labelKey='account.form.permission_preset' />} + name="permission_preset" + rules={[rules.required()]} > - <Switch /> + <Select + options={Object.values(PERMISSION_PRESETS).map(value => ({ + value, + label: t(`account.permission_preset.${value}`), + }))} + /> </Form.Item> <Form.Item label={<HelpLabel t={t} labelKey='account.form.remark' />} @@ -316,20 +338,42 @@ const EditLayer = ({ /> </Form.Item> <Form.Item - label={<HelpLabel t={t} labelKey='account.form.permission' />} - name="adminSpaces" + noStyle + shouldUpdate={permissionPresetChanged} > - <Select options={graphspaceList} mode="multiple" /> + {({getFieldValue}) => (getFieldValue('permission_preset') + !== PERMISSION_PRESETS.GS_ADMIN ? null : ( + <Form.Item + label={<HelpLabel t={t} labelKey='account.form.graphspaces' />} + name="graphspaces" + > + <Select options={graphspaceList} mode="multiple" /> + </Form.Item> + ))} </Form.Item> </> )} {op === 'auth' && ( - <Form.Item - label={<HelpLabel t={t} labelKey='account.form.permission' />} - name="adminSpaces" - > - <Select options={graphspaceList} mode="multiple" /> - </Form.Item> + <> + <Form.Item + label={<HelpLabel t={t} labelKey='account.form.permission_preset' />} + name="permission_preset" + rules={[rules.required()]} + > + <Select + options={[PERMISSION_PRESETS.GS_ADMIN].map(value => ({ + value, + label: t(`account.permission_preset.${value}`), + }))} + /> + </Form.Item> + <Form.Item + label={<HelpLabel t={t} labelKey='account.form.graphspaces' />} + name="graphspaces" + > + <Select options={graphspaceList} mode="multiple" /> + </Form.Item> + </> )} </Form> </Spin> diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js index 873e574b5..cd537c444 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js @@ -35,17 +35,45 @@ import {useTranslation} from 'react-i18next'; import * as api from '../../api'; import TableHeader from '../../components/TableHeader'; import {useAuthContext} from '../../auth/AuthContext'; +import {PERMISSION_PRESETS} from './permissionPresets'; const PAGE_ERROR_CONFIG = {suppressBusinessErrorToast: true}; const PAGE_PARAMS = {query: '', page_no: 1, page_size: 200}; -const PERMISSIONS = ['READ', 'WRITE', 'DELETE', 'EXECUTE']; -const DEFAULT_RESOURCES = JSON.stringify([ - {type: 'GREMLIN', label: '*', properties: null}, -], null, 2); - const responseRecords = response => response?.data?.records ?? []; -const responseList = response => (Array.isArray(response?.data) ? response.data : []); -const accessRowKey = row => `${row.role_id}:${row.target_id}`; +const rolePreset = role => { + const explicit = role?.permission_preset ?? role?.permissionPreset; + if (Object.values(PERMISSION_PRESETS).includes(explicit)) { + return explicit; + } + const names = [role?.role_name, role?.role_nickname, role?.name, + role?.nickname].filter(Boolean).map(name => name.toLowerCase()); + if (names.includes('observer')) { + return PERMISSION_PRESETS.GS_READ_ONLY; + } + if (names.includes('analyst')) { + return PERMISSION_PRESETS.GS_READ_WRITE; + } + const permissions = role?.permissions ?? role?.actions; + if (Array.isArray(permissions)) { + if (permissions.includes('delete') || permissions.includes('admin')) { + return PERMISSION_PRESETS.GS_ADMIN; + } + if (permissions.includes('write') || permissions.includes('update')) { + return PERMISSION_PRESETS.GS_READ_WRITE; + } + if (permissions.includes('read')) { + return PERMISSION_PRESETS.GS_READ_ONLY; + } + } + return null; +}; + +const roleLabel = (role, t) => { + const preset = rolePreset(role); + return preset + ? t(`account.permission_preset.${preset}`) + : role?.role_name ?? role?.role_nickname ?? t('common.label.unknown'); +}; const RowAction = ({row, onAction, children}) => { const handleClick = useCallback(() => onAction(row), [onAction, row]); @@ -122,15 +150,8 @@ const SpaceAccess = () => { const contextVersion = context?.context_version; const scopes = context?.scopes ?? {}; const memberActions = context?.actions?.members ?? []; - const roleActions = context?.actions?.roles ?? []; - const authorizationActions = context?.actions?.authorizations ?? []; const canAddMember = memberActions.includes('add'); const canRemoveMember = memberActions.includes('remove'); - const canCreateRole = roleActions.includes('create'); - const canUpdateRole = roleActions.includes('update'); - const canDeleteRole = roleActions.includes('delete'); - const canGrant = authorizationActions.includes('grant'); - const canRevoke = authorizationActions.includes('revoke'); const [selectedSpace, setSelectedSpace] = useState(''); const [allSpaces, setAllSpaces] = useState([]); const [spacesLoading, setSpacesLoading] = useState(false); @@ -138,14 +159,8 @@ const SpaceAccess = () => { const [spacesRevision, setSpacesRevision] = useState(0); const spacesRequest = useRef(null); const [memberDialog, setMemberDialog] = useState(null); - const [roleDialog, setRoleDialog] = useState(null); - const [targetDialog, setTargetDialog] = useState(null); - const [accessDialog, setAccessDialog] = useState(null); const [submitting, setSubmitting] = useState(false); const [memberForm] = Form.useForm(); - const [roleForm] = Form.useForm(); - const [targetForm] = Form.useForm(); - const [accessForm] = Form.useForm(); const scopedSpaces = useMemo( () => scopes.admin_graphspaces ?? [], @@ -200,31 +215,17 @@ const SpaceAccess = () => { const loadRoles = useCallback(space => api.auth.getSpaceRoles( space, PAGE_PARAMS, PAGE_ERROR_CONFIG ), []); - const loadTargets = useCallback(space => api.auth.getSpaceTargets( - space, PAGE_PARAMS, PAGE_ERROR_CONFIG - ), []); - const loadAccesses = useCallback(space => api.auth.getSpaceAccesses( - space, {}, PAGE_ERROR_CONFIG - ), []); const members = useScopedResource( graphSpace, contextVersion, loadMembers, responseRecords ); const roles = useScopedResource( graphSpace, contextVersion, loadRoles, responseRecords ); - const targets = useScopedResource( - graphSpace, contextVersion, loadTargets, responseRecords - ); - const accesses = useScopedResource( - graphSpace, contextVersion, loadAccesses, responseList - ); const refreshAll = useCallback(() => { members.retry(); roles.retry(); - targets.retry(); - accesses.retry(); - }, [accesses, members, roles, targets]); + }, [members, roles]); const runMutation = useCallback(async (operation, close) => { if (submitting) { @@ -252,63 +253,26 @@ const SpaceAccess = () => { const openMember = useCallback(row => { memberForm.setFieldsValue({ user_id: row?.user_id, - roles: row?.roles?.map(role => role.role_id) ?? [], + permission_preset: rolePreset(row?.roles?.[0]), }); setMemberDialog(row ?? {}); }, [memberForm]); - const openRole = useCallback(row => { - roleForm.setFieldsValue({ - role_name: row?.role_name ?? row?.role_nickname, - role_description: row?.role_description, - }); - setRoleDialog(row ?? {}); - }, [roleForm]); - const openTarget = useCallback(row => { - targetForm.setFieldsValue({ - target_name: row?.target_name, - target_graph: row?.target_graph, - target_description: row?.target_description, - target_resources: row?.target_resources - ? JSON.stringify(row.target_resources, null, 2) - : DEFAULT_RESOURCES, - }); - setTargetDialog(row ?? {}); - }, [targetForm]); - const openAccess = useCallback(row => { - accessForm.setFieldsValue({ - role_id: row?.role_id, - target_id: row?.target_id, - permissions: row?.permissions ?? [], - }); - setAccessDialog(row ?? {}); - }, [accessForm]); - const closeMember = useCallback(() => { setMemberDialog(null); memberForm.resetFields(); }, [memberForm]); - const closeRole = useCallback(() => { - setRoleDialog(null); - roleForm.resetFields(); - }, [roleForm]); - const closeTarget = useCallback(() => { - setTargetDialog(null); - targetForm.resetFields(); - }, [targetForm]); - const closeAccess = useCallback(() => { - setAccessDialog(null); - accessForm.resetFields(); - }, [accessForm]); - const submitMember = useCallback(values => { - const roleLookup = new Map(roles.data.map(role => [role.id, role])); + const role = roles.data.find(item => rolePreset(item) === values.permission_preset); + if (!role) { + message.error(t('account.space_access.member.preset_unavailable')); + return; + } const payload = { user_id: values.user_id, - roles: values.roles.map(id => ({ - role_id: id, - role_name: roleLookup.get(id)?.role_name - ?? roleLookup.get(id)?.role_nickname ?? id, - })), + roles: role ? [{ + role_id: role.id, + role_name: role.role_name ?? role.role_nickname ?? values.permission_preset, + }] : [], }; const operation = memberDialog?.user_id ? () => api.auth.updateSpaceMember( @@ -318,54 +282,7 @@ const SpaceAccess = () => { graphSpace, payload, PAGE_ERROR_CONFIG ); runMutation(operation, closeMember); - }, [closeMember, graphSpace, memberDialog, roles.data, runMutation]); - - const submitRole = useCallback(values => { - const operation = roleDialog?.id - ? () => api.auth.updateSpaceRole( - graphSpace, roleDialog.id, values, PAGE_ERROR_CONFIG - ) - : () => api.auth.addSpaceRole( - graphSpace, values, PAGE_ERROR_CONFIG - ); - runMutation(operation, closeRole); - }, [closeRole, graphSpace, roleDialog, runMutation]); - - const submitTarget = useCallback(values => { - let resources; - try { - resources = JSON.parse(values.target_resources); - if (!Array.isArray(resources)) { - throw new Error('resources must be an array'); - } - } - catch (error) { - targetForm.setFields([{ - name: 'target_resources', - errors: [t('account.space_access.target.resources_invalid')], - }]); - return; - } - const payload = { - target_name: values.target_name, - target_graph: values.target_graph, - target_description: values.target_description, - target_resources: resources, - }; - const operation = targetDialog?.id - ? () => api.auth.updateSpaceTarget( - graphSpace, targetDialog.id, payload, PAGE_ERROR_CONFIG - ) - : () => api.auth.addSpaceTarget( - graphSpace, payload, PAGE_ERROR_CONFIG - ); - runMutation(operation, closeTarget); - }, [closeTarget, graphSpace, runMutation, t, targetDialog, targetForm]); - - const submitAccess = useCallback(values => { - runMutation(() => api.auth.saveSpaceAccess(graphSpace, values, - PAGE_ERROR_CONFIG), closeAccess); - }, [closeAccess, graphSpace, runMutation]); + }, [closeMember, graphSpace, memberDialog, roles.data, runMutation, t]); const confirmDelete = useCallback((title, operation) => { Modal.confirm({ @@ -381,34 +298,11 @@ const SpaceAccess = () => { graphSpace, row.user_id, PAGE_ERROR_CONFIG ) ), [confirmDelete, graphSpace, t]); - const editRole = useCallback(row => openRole(row), [openRole]); - const deleteRole = useCallback(row => confirmDelete( - t('account.space_access.role.delete_confirm'), - () => api.auth.deleteSpaceRole(graphSpace, row.id, PAGE_ERROR_CONFIG) - ), [confirmDelete, graphSpace, t]); - const editTarget = useCallback(row => openTarget(row), [openTarget]); - const deleteTarget = useCallback(row => confirmDelete( - t('account.space_access.target.delete_confirm'), - () => api.auth.deleteSpaceTarget(graphSpace, row.id, PAGE_ERROR_CONFIG) - ), [confirmDelete, graphSpace, t]); - const editAccess = useCallback(row => openAccess(row), [openAccess]); - const deleteAccess = useCallback(row => confirmDelete( - t('account.space_access.authorization.delete_confirm'), - () => api.auth.deleteSpaceAccess( - graphSpace, row.role_id, row.target_id, PAGE_ERROR_CONFIG - ) - ), [confirmDelete, graphSpace, t]); const addMember = useCallback(() => openMember(), [openMember]); - const addRole = useCallback(() => openRole(), [openRole]); - const addTarget = useCallback(() => openTarget(), [openTarget]); - const addAccess = useCallback(() => openAccess(), [openAccess]); const retrySpaces = useCallback( () => setSpacesRevision(value => value + 1), [] ); const submitMemberForm = useCallback(() => memberForm.submit(), [memberForm]); - const submitRoleForm = useCallback(() => roleForm.submit(), [roleForm]); - const submitTargetForm = useCallback(() => targetForm.submit(), [targetForm]); - const submitAccessForm = useCallback(() => accessForm.submit(), [accessForm]); const memberColumns = [ {title: t('account.space_access.member.id'), dataIndex: 'user_id'}, @@ -417,7 +311,7 @@ const SpaceAccess = () => { title: t('account.space_access.member.roles'), dataIndex: 'roles', render: value => value?.map(role => ( - <Tag key={role.role_id}>{role.role_name}</Tag> + <Tag key={role.role_id}>{roleLabel(role, t)}</Tag> )), }, ...((canAddMember || canRemoveMember) ? [{ @@ -439,86 +333,6 @@ const SpaceAccess = () => { }] : []), ]; - const roleColumns = [ - {title: t('account.space_access.role.name'), dataIndex: 'role_name'}, - { - title: t('account.space_access.role.description'), - dataIndex: 'role_description', - }, - ...((canUpdateRole || canDeleteRole) ? [{ - title: t('common.operation'), - render: row => ( - <Space> - {canUpdateRole && ( - <RowAction row={row} onAction={editRole}> - {t('common.action.edit')} - </RowAction> - )} - {canDeleteRole && ( - <RowAction row={row} onAction={deleteRole}> - {t('common.action.delete')} - </RowAction> - )} - </Space> - ), - }] : []), - ]; - - const targetColumns = [ - {title: t('account.space_access.target.name'), dataIndex: 'target_name'}, - {title: t('account.space_access.target.graph'), dataIndex: 'target_graph'}, - { - title: t('account.space_access.target.description'), - dataIndex: 'target_description', - }, - ...((canGrant || canRevoke) ? [{ - title: t('common.operation'), - render: row => ( - <Space> - {canGrant && ( - <RowAction row={row} onAction={editTarget}> - {t('common.action.edit')} - </RowAction> - )} - {canRevoke && ( - <RowAction row={row} onAction={deleteTarget}> - {t('common.action.delete')} - </RowAction> - )} - </Space> - ), - }] : []), - ]; - - const accessColumns = [ - {title: t('account.space_access.role.name'), dataIndex: 'role_name'}, - {title: t('account.space_access.target.name'), dataIndex: 'target_name'}, - { - title: t('account.space_access.authorization.permissions'), - dataIndex: 'permissions', - render: value => value?.map(permission => ( - <Tag key={permission}>{permission}</Tag> - )), - }, - ...((canGrant || canRevoke) ? [{ - title: t('common.operation'), - render: row => ( - <Space> - {canGrant && ( - <RowAction row={row} onAction={editAccess}> - {t('common.action.edit')} - </RowAction> - )} - {canRevoke && ( - <RowAction row={row} onAction={deleteAccess}> - {t('common.action.delete')} - </RowAction> - )} - </Space> - ), - }] : []), - ]; - const table = (resource, columns, rowKey, addLabel, onAdd, canAdd) => ( <> <ErrorAlert error={resource.error} retry={resource.retry} t={t} /> @@ -583,34 +397,6 @@ const SpaceAccess = () => { addMember, canAddMember ), }, - { - key: 'roles', - label: t('account.space_access.tabs.roles'), - children: table( - roles, roleColumns, 'id', - t('account.space_access.role.add'), - addRole, canCreateRole - ), - }, - { - key: 'targets', - label: t('account.space_access.tabs.targets'), - children: table( - targets, targetColumns, 'id', - t('account.space_access.target.add'), - addTarget, canGrant - ), - }, - { - key: 'authorizations', - label: t('account.space_access.tabs.authorizations'), - children: table( - accesses, accessColumns, - accessRowKey, - t('account.space_access.authorization.add'), - addAccess, canGrant - ), - }, ]} /> @@ -631,135 +417,22 @@ const SpaceAccess = () => { <Input disabled={Boolean(memberDialog?.user_id)} /> </Form.Item> <Form.Item - name="roles" + name="permission_preset" label={t('account.space_access.member.roles')} - rules={[{required: true, type: 'array', min: 1}]} - > - <Select - mode="multiple" - options={roles.data.map(role => ({ - value: role.id, - label: role.role_name ?? role.role_nickname, - }))} - /> - </Form.Item> - </Form> - </Modal> - - <Modal - open={roleDialog !== null} - title={t('account.space_access.role.dialog')} - onCancel={closeRole} - onOk={submitRoleForm} - confirmLoading={submitting} - destroyOnClose - > - <Form form={roleForm} layout="vertical" onFinish={submitRole}> - <Form.Item - name="role_name" - label={t('account.space_access.role.name')} - rules={[{required: true}]} - > - <Input /> - </Form.Item> - <Form.Item - name="role_description" - label={t('account.space_access.role.description')} - > - <Input /> - </Form.Item> - </Form> - </Modal> - - <Modal - open={targetDialog !== null} - title={t('account.space_access.target.dialog')} - onCancel={closeTarget} - onOk={submitTargetForm} - confirmLoading={submitting} - destroyOnClose - > - <Form form={targetForm} layout="vertical" onFinish={submitTarget}> - <Form.Item - name="target_name" - label={t('account.space_access.target.name')} - rules={[{required: true}]} - > - <Input disabled={Boolean(targetDialog?.id)} /> - </Form.Item> - <Form.Item - name="target_graph" - label={t('account.space_access.target.graph')} rules={[{required: true}]} - > - <Input disabled={Boolean(targetDialog?.id)} /> - </Form.Item> - <Form.Item - name="target_description" - label={t('account.space_access.target.description')} - > - <Input /> - </Form.Item> - <Form.Item - name="target_resources" - label={t('account.space_access.target.resources')} - rules={[{required: true}]} - > - <Input.TextArea autoSize={{minRows: 5, maxRows: 12}} /> - </Form.Item> - </Form> - </Modal> - - <Modal - open={accessDialog !== null} - title={t('account.space_access.authorization.dialog')} - onCancel={closeAccess} - onOk={submitAccessForm} - confirmLoading={submitting} - destroyOnClose - > - <Form form={accessForm} layout="vertical" onFinish={submitAccess}> - <Form.Item - name="role_id" - label={t('account.space_access.role.name')} - rules={[{required: true}]} - > - <Select - disabled={Boolean(accessDialog?.role_id)} - options={roles.data.map(role => ({ - value: role.id, - label: role.role_name ?? role.role_nickname, - }))} - /> - </Form.Item> - <Form.Item - name="target_id" - label={t('account.space_access.target.name')} - rules={[{required: true}]} - > - <Select - disabled={Boolean(accessDialog?.target_id)} - options={targets.data.map(target => ({ - value: target.id, - label: target.target_name, - }))} - /> - </Form.Item> - <Form.Item - name="permissions" - label={t('account.space_access.authorization.permissions')} - rules={[{required: true, type: 'array', min: 1}]} > <Select - mode="multiple" - options={PERMISSIONS.map(permission => ({ - value: permission, - label: permission, - }))} + options={Object.values(PERMISSION_PRESETS) + .filter(value => value !== PERMISSION_PRESETS.SUPER_ADMIN) + .map(value => ({ + value, + label: t(`account.permission_preset.${value}`), + }))} /> </Form.Item> </Form> </Modal> + </> ); }; diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js index 5fbd54c8f..a74866cc8 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js @@ -16,7 +16,7 @@ * limitations under the License. */ -import {act, render, screen, waitFor} from '@testing-library/react'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; import SpaceAccess from './SpaceAccess'; import * as api from '../../api'; @@ -109,12 +109,8 @@ test('uses only path-scoped APIs for a space administrator', async () => { expect(api.auth.getSpaceRoles).toHaveBeenCalledWith( 'SPACE_A', expect.any(Object), expect.any(Object) ); - expect(api.auth.getSpaceTargets).toHaveBeenCalledWith( - 'SPACE_A', expect.any(Object), expect.any(Object) - ); - expect(api.auth.getSpaceAccesses).toHaveBeenCalledWith( - 'SPACE_A', expect.any(Object), expect.any(Object) - ); + expect(api.auth.getSpaceTargets).not.toHaveBeenCalled(); + expect(api.auth.getSpaceAccesses).not.toHaveBeenCalled(); expect(api.auth.getAllUserList).not.toHaveBeenCalled(); expect(api.manage.getGraphSpaceList).not.toHaveBeenCalled(); }); @@ -172,3 +168,54 @@ test('does not infer mutations when the server grants read-only actions', async name: 'common.action.delete', })).not.toBeInTheDocument(); }); + +test('maps a selected preset to the authoritative role id when adding a member', async () => { + api.auth.getSpaceRoles.mockResolvedValueOnce(page([{ + id: 'writer-id', + permission_preset: 'GS_READ_WRITE', + }])); + api.auth.addSpaceMember.mockResolvedValue({status: 200}); + render(<SpaceAccess />); + + await screen.findAllByText('alice'); + fireEvent.click(screen.getByRole('button', { + name: 'account.space_access.member.add', + })); + const textboxes = screen.getAllByRole('textbox'); + fireEvent.change(textboxes[textboxes.length - 1], { + target: {value: 'bob'}, + }); + const comboboxes = screen.getAllByRole('combobox'); + fireEvent.mouseDown(comboboxes[comboboxes.length - 1]); + fireEvent.click(screen.getByText('account.permission_preset.GS_READ_WRITE')); + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => expect(api.auth.addSpaceMember).toHaveBeenCalledWith( + 'SPACE_A', + { + user_id: 'bob', + roles: [{role_id: 'writer-id', role_name: 'GS_READ_WRITE'}], + }, + expect.any(Object) + )); +}); + +test('does not submit when the selected preset has no server role', async () => { + api.auth.addSpaceMember.mockResolvedValue({status: 200}); + render(<SpaceAccess />); + + await screen.findAllByText('alice'); + fireEvent.click(screen.getByRole('button', { + name: 'account.space_access.member.add', + })); + const textboxes = screen.getAllByRole('textbox'); + fireEvent.change(textboxes[textboxes.length - 1], { + target: {value: 'bob'}, + }); + const comboboxes = screen.getAllByRole('combobox'); + fireEvent.mouseDown(comboboxes[comboboxes.length - 1]); + fireEvent.click(screen.getByText('account.permission_preset.GS_READ_WRITE')); + fireEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => expect(api.auth.addSpaceMember).not.toHaveBeenCalled()); +}); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js index 04c3d7126..c08a3407c 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js @@ -146,7 +146,7 @@ test('loads graphspaces into the visible create account form', async () => { status: 200, data: {records: [{name: 'analytics'}]}, })); - fireEvent.mouseDown(screen.getByRole('combobox')); + fireEvent.mouseDown(screen.getAllByRole('combobox')[1]); expect(await screen.findByRole('option', {name: 'analytics'})).toBeInTheDocument(); }); @@ -172,5 +172,7 @@ test('shows the derived space administrator level in account details', async () render(<EditLayer {...props} data={{id: 'space-admin'}} op='detail' />); - expect(await screen.findByText('account.level.SPACEADMIN')).toBeInTheDocument(); + expect(await screen.findByText( + 'account.permission_preset.GS_ADMIN' + )).toBeInTheDocument(); }); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/account-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/account-recovery.test.js index 7b3566887..ad74bcc28 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/account-recovery.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/account-recovery.test.js @@ -111,9 +111,12 @@ test('labels administrators, space administrators, and regular users in the list render(<Account />); - expect(await screen.findByText('account.level.ADMIN')).toBeInTheDocument(); - expect(screen.getByText('account.level.SPACEADMIN')).toBeInTheDocument(); - expect(screen.getByText('account.level.USER')).toBeInTheDocument(); + expect(await screen.findByText('account.permission_preset.SUPER_ADMIN')) + .toBeInTheDocument(); + expect(screen.getByText('account.permission_preset.GS_ADMIN')) + .toBeInTheDocument(); + expect(screen.getByText('account.permission_preset.GS_READ_ONLY')) + .toBeInTheDocument(); }); test('space administrators use scoped management without loading global accounts', async () => { diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/index.js b/hugegraph-hubble/hubble-fe/src/pages/Account/index.js index e1f5f0efa..7c0bd4aae 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/index.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/index.js @@ -34,7 +34,7 @@ import TableHeader from '../../components/TableHeader'; import EditLayer from './EditLayer'; import * as api from '../../api'; import {useAuthContext} from '../../auth/AuthContext'; -import {getAccountLevel} from './level'; +import {getAccountPreset, PERMISSION_PRESETS} from './permissionPresets'; import SpaceAccess from './SpaceAccess'; const PAGE_ERROR_CONFIG = {suppressBusinessErrorToast: true}; @@ -136,10 +136,10 @@ const GlobalAccounts = () => { title: t('account.col.level'), width: 140, render: row => { - const level = getAccountLevel(row); - const color = level === 'ADMIN' ? 'red' - : level === 'SPACEADMIN' ? 'blue' : 'default'; - return <Tag color={color}>{t(`account.level.${level}`)}</Tag>; + const preset = getAccountPreset(row); + const color = preset === PERMISSION_PRESETS.SUPER_ADMIN ? 'red' + : preset === PERMISSION_PRESETS.GS_ADMIN ? 'blue' : 'default'; + return <Tag color={color}>{t(`account.permission_preset.${preset}`)}</Tag>; }, }, { diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.js b/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.js new file mode 100644 index 000000000..99fb9a64e --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.js @@ -0,0 +1,79 @@ +/* + * 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. + */ + +const PERMISSION_PRESETS = Object.freeze({ + SUPER_ADMIN: 'SUPER_ADMIN', + GS_READ_ONLY: 'GS_READ_ONLY', + GS_READ_WRITE: 'GS_READ_WRITE', + GS_ADMIN: 'GS_ADMIN', +}); + +const presetKeys = Object.values(PERMISSION_PRESETS); + +const getAccountPreset = account => { + const explicit = account?.permission_preset + ?? account?.permissionPreset + ?? account?.access_level; + if (presetKeys.includes(explicit)) { + return explicit; + } + if (account?.is_superadmin) { + return PERMISSION_PRESETS.SUPER_ADMIN; + } + const scopedPresets = (account?.graphspace_permissions ?? []) + .map(permission => permission?.permission_preset) + .filter(preset => presetKeys.includes(preset)); + if (scopedPresets.includes(PERMISSION_PRESETS.GS_ADMIN)) { + return PERMISSION_PRESETS.GS_ADMIN; + } + if (scopedPresets.includes(PERMISSION_PRESETS.GS_READ_WRITE)) { + return PERMISSION_PRESETS.GS_READ_WRITE; + } + if (Array.isArray(account?.adminSpaces) && account.adminSpaces.length > 0) { + return PERMISSION_PRESETS.GS_ADMIN; + } + return PERMISSION_PRESETS.GS_READ_ONLY; +}; + +const getPresetSpaces = account => { + const spaces = account?.graphspace_permissions ?? account?.adminSpaces; + return Array.isArray(spaces) + ? spaces.map(space => (typeof space === 'string' + ? space : space.name ?? space.graphspace)).filter(Boolean) + : []; +}; + +// This is the only compatibility mapping used by account forms. +const toPermissionPayload = values => { + const preset = values.permission_preset ?? PERMISSION_PRESETS.GS_READ_ONLY; + const spaces = values.graphspaces ?? []; + const payload = {...values}; + delete payload.permission_preset; + delete payload.graphspaces; + return { + ...payload, + permission_preset: preset, + graphspace_permissions: spaces.map(graphspace => ({ + graphspace, + permission_preset: preset, + })), + is_superadmin: preset === PERMISSION_PRESETS.SUPER_ADMIN, + adminSpaces: preset === PERMISSION_PRESETS.GS_ADMIN ? spaces : [], + }; +}; + +export {PERMISSION_PRESETS, getAccountPreset, getPresetSpaces, toPermissionPayload}; diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.test.js new file mode 100644 index 000000000..cf287cae4 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.test.js @@ -0,0 +1,70 @@ +/* + * 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 { + getAccountPreset, + getPresetSpaces, + PERMISSION_PRESETS, + toPermissionPayload, +} from './permissionPresets'; + +test.each([ + [{is_superadmin: true}, PERMISSION_PRESETS.SUPER_ADMIN], + [{permission_preset: 'GS_READ_WRITE'}, PERMISSION_PRESETS.GS_READ_WRITE], + [{graphspace_permissions: [{ + graphspace: 'team', + permission_preset: 'GS_READ_WRITE', + }]}, PERMISSION_PRESETS.GS_READ_WRITE], + [{adminSpaces: ['team']}, PERMISSION_PRESETS.GS_ADMIN], + [{adminSpaces: []}, PERMISSION_PRESETS.GS_READ_ONLY], +])('normalizes account %j to %s', (account, expected) => { + expect(getAccountPreset(account)).toBe(expected); +}); + +test('normalizes GraphSpace objects and keeps legacy payload in one adapter', () => { + expect(getPresetSpaces({adminSpaces: [{name: 'team'}]})).toEqual(['team']); + expect(getPresetSpaces({ + graphspace_permissions: [{graphspace: 'team'}], + })).toEqual(['team']); + expect(toPermissionPayload({ + permission_preset: PERMISSION_PRESETS.GS_ADMIN, + graphspaces: ['team'], + })).toMatchObject({ + adminSpaces: ['team'], + is_superadmin: false, + }); + expect(toPermissionPayload({ + permission_preset: PERMISSION_PRESETS.GS_READ_WRITE, + graphspaces: ['team'], + })).toMatchObject({ + permission_preset: PERMISSION_PRESETS.GS_READ_WRITE, + graphspace_permissions: [{ + graphspace: 'team', + permission_preset: PERMISSION_PRESETS.GS_READ_WRITE, + }], + }); +}); + +test('clears admin spaces for the super administrator preset', () => { + expect(toPermissionPayload({ + permission_preset: PERMISSION_PRESETS.SUPER_ADMIN, + graphspaces: ['team'], + })).toMatchObject({ + adminSpaces: [], + is_superadmin: true, + }); +}); diff --git a/hugegraph-hubble/hubble-fe/src/routes/index.js b/hugegraph-hubble/hubble-fe/src/routes/index.js index 34789a2ce..8d7a27599 100644 --- a/hugegraph-hubble/hubble-fe/src/routes/index.js +++ b/hugegraph-hubble/hubble-fe/src/routes/index.js @@ -42,7 +42,7 @@ import OperationsRoute, { // 图分析的路由 import GraphAnalysis from '../pages/GraphAnalysis'; import AsyncTaskResultPage from '../pages/AsyncTaskResult'; -import {isPdEnabled} from '../utils/config'; +import {isAuthEnabled, isPdEnabled} from '../utils/config'; import { DEFAULT_GRAPHSPACE, shouldUseNonPdDefaultGraphspace, @@ -64,8 +64,12 @@ const isLoggedIn = () => { const ProtectedRoute = ({children}) => { const location = useLocation(); + // Keep older test/integration shims that only expose PD configuration + // conservative until they learn the auth capability. + const authEnabled = typeof isAuthEnabled === 'function' + ? isAuthEnabled() : true; - if (isLoggedIn()) { + if (!authEnabled || isLoggedIn()) { return children; } diff --git a/hugegraph-hubble/hubble-fe/src/utils/config.js b/hugegraph-hubble/hubble-fe/src/utils/config.js index 55964130e..e17acbf93 100644 --- a/hugegraph-hubble/hubble-fe/src/utils/config.js +++ b/hugegraph-hubble/hubble-fe/src/utils/config.js @@ -31,4 +31,8 @@ const isPdEnabled = () => { return getConfig().pd_enabled; }; -export {setConfig, getConfig, isPdEnabled}; +const isAuthEnabled = () => { + return getConfig().auth_enabled !== false; +}; + +export {setConfig, getConfig, isPdEnabled, isAuthEnabled};
