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 554f92b6b16a4bc4c880923c361daec63301c2a9 Author: dark <[email protected]> AuthorDate: Sun Aug 16 16:40:07 2026 +0800 fix(hubble): harden compatibility contracts - preserve member-manager compatibility - align version and CI capability boundaries - normalize PR-added source formatting --- AGENTS.md | 2 +- README.md | 6 +- .../org/apache/hugegraph/driver/HugeClient.java | 22 +- .../hugegraph/driver/ServerCompatibility.java | 24 ++- .../hugegraph/driver/ServerCompatibilityTest.java | 4 +- hugegraph-hubble/README.md | 15 +- .../docs/images/compat-1.5-standalone.png | Bin 0 -> 82286 bytes .../docs/images/compat-1.7-standalone.png | Bin 0 -> 81494 bytes .../docs/images/compat-1.8-auth-account.png | Bin 0 -> 68710 bytes .../docs/images/compat-1.8-auth-dashboard.png | Bin 0 -> 106470 bytes .../docs/images/compat-1.8-graphspace-access.png | Bin 0 -> 62654 bytes hugegraph-hubble/docs/images/compat-1.8-pd.png | Bin 0 -> 28788 bytes .../docs/images/hubble-auth-connection-root.png | Bin 56400 -> 0 bytes .../docs/images/hubble-auth-connection.png | Bin 41540 -> 0 bytes .../docs/images/hubble-graphspace-permissions.png | Bin 60284 -> 0 bytes .../docs/images/hubble-legacy-capability.png | Bin 45865 -> 0 bytes hugegraph-hubble/docs/images/hubble-non-auth.png | Bin 41540 -> 0 bytes .../docs/images/hubble-pd-connection.png | Bin 47441 -> 0 bytes .../hugegraph/controller/BaseController.java | 17 +- .../controller/auth/AccessController.java | 10 +- .../controller/auth/BelongController.java | 16 +- .../controller/auth/GraphSpaceUserController.java | 11 + .../hugegraph/controller/auth/LoginController.java | 5 +- .../hugegraph/controller/auth/RoleController.java | 12 +- .../controller/auth/TargetController.java | 12 +- .../controller/space/GraphSpaceController.java | 12 ++ .../hugegraph/handler/CustomInterceptor.java | 56 +++-- .../apache/hugegraph/handler/LoginInterceptor.java | 16 ++ .../apache/hugegraph/options/HubbleOptions.java | 6 +- .../hugegraph/service/HugeClientPoolService.java | 4 + .../hugegraph/service/auth/AuthContextService.java | 15 +- .../hugegraph/service/auth/AuthModeService.java | 2 +- .../service/auth/GraphSpaceUserService.java | 236 +++++++++++++++------ .../apache/hugegraph/service/auth/UserService.java | 161 +++++++++----- .../hugegraph/service/space/GraphSpaceService.java | 44 +++- .../GraphSpaceAuthMutationAuthorizationTest.java | 52 ++++- .../auth/GraphSpaceAuthOwnershipTest.java | 41 +++- .../service/auth/AuthContextServiceTest.java | 6 +- .../apache/hugegraph/unit/AuthSecurityTest.java | 60 ++++++ .../unit/UserServiceCompatibilityTest.java | 4 + hugegraph-hubble/hubble-fe/src/App.test.js | 36 +++- .../hubble-fe/src/api/auth-contract.test.js | 18 ++ hugegraph-hubble/hubble-fe/src/api/auth.js | 25 +++ .../hubble-fe/src/components/Sidebar/index.ant.js | 6 +- .../src/components/Sidebar/index.ant.test.js | 21 ++ .../src/i18n/resources/en-US/modules/pages.json | 4 +- .../src/i18n/resources/zh-CN/modules/pages.json | 4 +- .../hubble-fe/src/pages/Account/EditLayer.js | 22 +- .../hubble-fe/src/pages/Account/SpaceAccess.js | 84 +++++--- .../src/pages/Account/SpaceAccess.test.js | 62 ++++-- .../hubble-fe/src/pages/Account/index.js | 3 + .../src/pages/Account/permissionPresets.js | 32 ++- .../src/pages/Account/permissionPresets.test.js | 19 ++ hugegraph-hubble/hubble-fe/src/routes/index.js | 18 +- .../hubble-fe/src/routes/route-guard.test.js | 21 +- tools/checkstyle.xml | 2 +- 56 files changed, 964 insertions(+), 284 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4aa6cceaa..9f8ca9eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ mvn test -Dtest=FuncTestSuite -pl hugegraph-tools -ntp ## Code Style Checkstyle enforced via `tools/checkstyle.xml`: -- Max line length: 100 characters +- Max line length: 120 characters - 4-space indentation (no tabs) - No star imports - No `System.out.println` diff --git a/README.md b/README.md index 3eb7a6253..bff20dad7 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,11 @@ 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) 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 f02fd6146..578b9ce4b 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 @@ -24,6 +24,7 @@ import lombok.Getter; import org.apache.hugegraph.client.RestClient; import org.apache.hugegraph.rest.ClientException; import org.apache.hugegraph.rest.RestClientConfig; +import org.apache.hugegraph.structure.auth.User; import org.apache.hugegraph.util.VersionUtil; import org.apache.hugegraph.version.ClientVersion; import org.slf4j.Logger; @@ -57,6 +58,8 @@ public class HugeClient implements Closeable { */ private volatile boolean apiVersionChecked; private final Object apiVersionLock = new Object(); + private ServerCompatibility.Profile compatibility = + ServerCompatibility.Profile.LEGACY; private VersionManager version; private GraphsManager graphs; private SchemaManager schema; @@ -209,9 +212,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 = ServerCompatibility.supportsGraphSpace( - this.version.getCoreVersion()); - this.client.setSupportGs(supportGs); + this.compatibility = ServerCompatibility.profile(this.version.getCoreVersion()); + this.client.setSupportGs(this.compatibility.supportsGraphSpace()); } public GraphsManager graphs() { @@ -258,6 +260,20 @@ public class HugeClient implements Closeable { return this.auth; } + public boolean supportsDefaultRole() { + return this.compatibility.supportsDefaultRole(); + } + + public User findUserByName(String name) { + if (this.supportsDefaultRole()) { + return this.auth.getUserByName(name); + } + return this.auth.listUsers().stream() + .filter(user -> name.equals(user.name())) + .findFirst() + .orElse(null); + } + public MetricsManager metrics() { return this.metrics; } 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 index ac975d661..18fb6bd2c 100644 --- a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/ServerCompatibility.java +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/ServerCompatibility.java @@ -29,6 +29,7 @@ import org.apache.hugegraph.util.VersionUtil; public final class ServerCompatibility { private static final String GRAPHSPACE_MIN_VERSION = "1.7.0"; + private static final String DEFAULT_ROLE_MIN_VERSION = "1.8.0"; private ServerCompatibility() { } @@ -39,8 +40,10 @@ public final class ServerCompatibility { } try { String normalized = coreVersion.trim(); - return VersionUtil.gte(normalized, GRAPHSPACE_MIN_VERSION) ? - Profile.MODERN : Profile.LEGACY; + if (VersionUtil.gte(normalized, DEFAULT_ROLE_MIN_VERSION)) { + return Profile.MODERN; + } + return VersionUtil.gte(normalized, GRAPHSPACE_MIN_VERSION) ? Profile.GRAPHSPACE : Profile.LEGACY; } catch (RuntimeException ignored) { return Profile.LEGACY; } @@ -50,18 +53,29 @@ public final class ServerCompatibility { return profile(coreVersion).supportsGraphSpace(); } + public static boolean supportsDefaultRole(String coreVersion) { + return profile(coreVersion).supportsDefaultRole(); + } + public enum Profile { - LEGACY(false), - MODERN(true); + LEGACY(false, false), + GRAPHSPACE(true, false), + MODERN(true, true); private final boolean graphSpace; + private final boolean defaultRole; - Profile(boolean graphSpace) { + Profile(boolean graphSpace, boolean defaultRole) { this.graphSpace = graphSpace; + this.defaultRole = defaultRole; } public boolean supportsGraphSpace() { return this.graphSpace; } + + public boolean supportsDefaultRole() { + return this.defaultRole; + } } } 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 index f3bccbf17..96f98ad0c 100644 --- a/hugegraph-client/src/test/java/org/apache/hugegraph/driver/ServerCompatibilityTest.java +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/ServerCompatibilityTest.java @@ -35,7 +35,9 @@ public class ServerCompatibilityTest { 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, + Assert.assertFalse(ServerCompatibility.supportsDefaultRole("1.7.0")); + Assert.assertTrue(ServerCompatibility.supportsDefaultRole("1.8.0")); + Assert.assertEquals(ServerCompatibility.Profile.GRAPHSPACE, ServerCompatibility.profile("1.7.1")); } } diff --git a/hugegraph-hubble/README.md b/hugegraph-hubble/README.md index 5b3fb9955..4062f4f9f 100644 --- a/hugegraph-hubble/README.md +++ b/hugegraph-hubble/README.md @@ -34,15 +34,20 @@ being scattered through controllers or pages. See The following captures are collected from Chrome against the running Hubble application and document the supported states: - + - + - + - + - + + + + +The 1.5 and 1.7 captures show standalone non-auth core workflows. The 1.8/master +captures cover both PD/non-auth connection discovery and authenticated account / GraphSpace permission surfaces. ## Local development feedback loop diff --git a/hugegraph-hubble/docs/images/compat-1.5-standalone.png b/hugegraph-hubble/docs/images/compat-1.5-standalone.png new file mode 100644 index 000000000..34223b135 Binary files /dev/null and b/hugegraph-hubble/docs/images/compat-1.5-standalone.png differ diff --git a/hugegraph-hubble/docs/images/compat-1.7-standalone.png b/hugegraph-hubble/docs/images/compat-1.7-standalone.png new file mode 100644 index 000000000..c656488e2 Binary files /dev/null and b/hugegraph-hubble/docs/images/compat-1.7-standalone.png differ diff --git a/hugegraph-hubble/docs/images/compat-1.8-auth-account.png b/hugegraph-hubble/docs/images/compat-1.8-auth-account.png new file mode 100644 index 000000000..e86c1c2d7 Binary files /dev/null and b/hugegraph-hubble/docs/images/compat-1.8-auth-account.png differ diff --git a/hugegraph-hubble/docs/images/compat-1.8-auth-dashboard.png b/hugegraph-hubble/docs/images/compat-1.8-auth-dashboard.png new file mode 100644 index 000000000..9bbffb315 Binary files /dev/null and b/hugegraph-hubble/docs/images/compat-1.8-auth-dashboard.png differ diff --git a/hugegraph-hubble/docs/images/compat-1.8-graphspace-access.png b/hugegraph-hubble/docs/images/compat-1.8-graphspace-access.png new file mode 100644 index 000000000..537694b0d Binary files /dev/null and b/hugegraph-hubble/docs/images/compat-1.8-graphspace-access.png differ diff --git a/hugegraph-hubble/docs/images/compat-1.8-pd.png b/hugegraph-hubble/docs/images/compat-1.8-pd.png new file mode 100644 index 000000000..a72899a35 Binary files /dev/null and b/hugegraph-hubble/docs/images/compat-1.8-pd.png differ diff --git a/hugegraph-hubble/docs/images/hubble-auth-connection-root.png b/hugegraph-hubble/docs/images/hubble-auth-connection-root.png deleted file mode 100644 index 966624185..000000000 Binary files a/hugegraph-hubble/docs/images/hubble-auth-connection-root.png and /dev/null differ diff --git a/hugegraph-hubble/docs/images/hubble-auth-connection.png b/hugegraph-hubble/docs/images/hubble-auth-connection.png deleted file mode 100644 index f205e3621..000000000 Binary files a/hugegraph-hubble/docs/images/hubble-auth-connection.png and /dev/null differ diff --git a/hugegraph-hubble/docs/images/hubble-graphspace-permissions.png b/hugegraph-hubble/docs/images/hubble-graphspace-permissions.png deleted file mode 100644 index 06f4a0c3f..000000000 Binary files a/hugegraph-hubble/docs/images/hubble-graphspace-permissions.png and /dev/null differ diff --git a/hugegraph-hubble/docs/images/hubble-legacy-capability.png b/hugegraph-hubble/docs/images/hubble-legacy-capability.png deleted file mode 100644 index 982b789dc..000000000 Binary files a/hugegraph-hubble/docs/images/hubble-legacy-capability.png and /dev/null differ diff --git a/hugegraph-hubble/docs/images/hubble-non-auth.png b/hugegraph-hubble/docs/images/hubble-non-auth.png deleted file mode 100644 index f205e3621..000000000 Binary files a/hugegraph-hubble/docs/images/hubble-non-auth.png and /dev/null differ diff --git a/hugegraph-hubble/docs/images/hubble-pd-connection.png b/hugegraph-hubble/docs/images/hubble-pd-connection.png deleted file mode 100644 index 8decb8bf6..000000000 Binary files a/hugegraph-hubble/docs/images/hubble-pd-connection.png and /dev/null differ 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 db091fe14..faf93d6ba 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 @@ -146,10 +146,9 @@ public abstract class BaseController { client.assignGraph(graphSpace, graph); return client; } - HugeClient client = this.authMode.anonymous() ? - this.hugeClientPoolService.createUnauthClient() : - this.hugeClientPoolService.createAuthClient( - graphSpace, graph, this.getToken()); + HugeClient client = this.authMode != null && this.authMode.anonymous() ? + this.hugeClientPoolService.createUnauthClient(graphSpace, graph) : + this.hugeClientPoolService.createAuthClient(graphSpace, graph, this.getToken()); if (graphSpace != null || graph != null) { client.assignGraph(graphSpace, graph); } @@ -177,6 +176,16 @@ public abstract class BaseController { return client; } + protected HugeClient requireGraphSpaceAuthorizationAdmin( + String graphSpace) { + HugeClient client = this.authClient(null, null); + if (!this.userService.isSuperAdmin(client)) { + throw new ForbiddenException("Permission denied: manage authorization objects"); + } + client.assignGraph(graphSpace, null); + return client; + } + protected HugeClient requireGraphSpaceAdministrator() { HugeClient client = this.authClient(null, null); if (!this.userService.isSuperAdmin(client)) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java index 0f97026ed..f2bbc78f5 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java @@ -47,28 +47,28 @@ public class AccessController extends AuthController { @PathVariable("graphspace") String graphSpace, @RequestParam(value = "role_id", required = false) String roleId, @RequestParam(value = "target_id", required = false) String targetId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.accessService.list(client, graphSpace, roleId, targetId); } @GetMapping("{id}") public AccessEntity get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String accessId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.accessService.get(client, graphSpace, accessId); } @PostMapping public AccessEntity add(@PathVariable("graphspace") String graphSpace, @RequestBody AccessEntity accessEntity) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.accessService.addOrUpdate(client, graphSpace, accessEntity); } @PutMapping public AccessEntity update(@PathVariable("graphspace") String graphSpace, @RequestBody AccessEntity accessEntity) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.accessService.addOrUpdate(client, graphSpace, accessEntity); } @@ -76,7 +76,7 @@ public class AccessController extends AuthController { public void delete(@PathVariable("graphspace") String graphSpace, @RequestParam("role_id") String roleId, @RequestParam("target_id") String targetId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.accessService.delete(client, graphSpace, roleId, targetId); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java index 757a4152b..1f7ce5660 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java @@ -48,7 +48,7 @@ public class BelongController extends AuthController { @PathVariable("graphspace") String graphSpace, @RequestParam(value = "role_id", required = false) String roleId, @RequestParam(value = "user_id", required = false) String userId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.belongService.list(client, graphSpace, roleId, userId); } @@ -61,7 +61,7 @@ public class BelongController extends AuthController { defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.belongService.listPage(client, graphSpace, roleId, userId, pageNo, pageSize); } @@ -69,14 +69,14 @@ public class BelongController extends AuthController { @GetMapping("{id}") public BelongEntity get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String belongId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.belongService.get(client, graphSpace, belongId); } @PostMapping public void create(@PathVariable("graphspace") String graphSpace, @RequestBody BelongEntity belongEntity) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.belongService.add(client, graphSpace, belongEntity.getRoleId(), belongEntity.getUserId()); } @@ -84,7 +84,7 @@ public class BelongController extends AuthController { @PostMapping("ids") public void createMany(@PathVariable("graphspace") String graphSpace, @RequestBody BelongService.BelongsReq belongsReq) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); for (String userId : belongsReq.getUserIds()) { this.belongService.add(client, graphSpace, belongsReq.getRoleId(), userId); @@ -94,7 +94,7 @@ public class BelongController extends AuthController { @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String belongId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.belongService.deleteById(client, graphSpace, belongId); } @@ -102,7 +102,7 @@ public class BelongController extends AuthController { public void delete(@PathVariable("graphspace") String graphSpace, @RequestParam("role_id") String roleId, @RequestParam("user_id") String userId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); if (StringUtils.isNotEmpty(roleId) && StringUtils.isNotEmpty(userId)) { this.belongService.delete(client, graphSpace, roleId, userId); } @@ -111,7 +111,7 @@ public class BelongController extends AuthController { @PostMapping("delids") public void deleteMany(@PathVariable("graphspace") String graphSpace, @RequestBody DelIdsReq delIdsReq) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.belongService.deleteMany(client, graphSpace, delIdsReq.ids.toArray(new String[0])); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java index 8ec43ae5a..a4a9832f0 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java @@ -18,6 +18,8 @@ package org.apache.hugegraph.controller.auth; +import java.util.Map; + import com.baomidou.mybatisplus.core.metadata.IPage; import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.driver.HugeClient; @@ -86,6 +88,15 @@ public class GraphSpaceUserController extends AuthController { return client.auth().addSpaceAdmin(userId, graphSpace); } + @PutMapping("{id}/preset") + public void setPermissionPreset( + @PathVariable("graphspace") String graphSpace, + @PathVariable("id") String userId, + @RequestBody Map<String, String> body) { + HugeClient client = this.requireGraphSpaceManager(graphSpace); + this.userService.applySpacePreset(client, graphSpace, userId, body.get("permission_preset")); + } + @DeleteMapping("spaceadmin/{id}") public void removeGraphSpaceAdmin( @PathVariable("graphspace") String graphSpace, 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 3400d066b..c5a51e226 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,7 +75,7 @@ public class LoginController extends BaseController { @PostMapping("/login") public Object login(@RequestBody Login login) { - if (this.authMode.anonymous()) { + if (this.authMode != null && this.authMode.anonymous()) { throw new ExternalException(HttpStatus.FORBIDDEN.value(), "Authentication is disabled"); } @@ -229,6 +229,9 @@ public class LoginController extends BaseController { @GetMapping("/status") public Object status() { + if (this.authMode != null && this.authMode.anonymous()) { + return ImmutableMap.of("level", "ANONYMOUS"); + } HugeClient client = authClient(null, null); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java index 06e0e268f..f7ed393f6 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java @@ -46,7 +46,7 @@ public class RoleController extends AuthController { @GetMapping("list") public List<Role> listName(@PathVariable("graphspace") String graphSpace) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.roleService.list(client, graphSpace, this.userService.isSuperAdmin(client)); } @@ -60,7 +60,7 @@ public class RoleController extends AuthController { defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.roleService.queryPage( client, graphSpace, query, pageNo, pageSize, this.userService.isSuperAdmin(client)); @@ -69,7 +69,7 @@ public class RoleController extends AuthController { @GetMapping("{id}") public Role get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String roleId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.roleService.get(client, graphSpace, roleId, this.userService.isSuperAdmin(client)); } @@ -77,7 +77,7 @@ public class RoleController extends AuthController { @PostMapping public Role add(@PathVariable("graphspace") String graphSpace, @RequestBody Role role) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); role.graphSpace(graphSpace); return this.roleService.insert(client, graphSpace, role); } @@ -86,7 +86,7 @@ public class RoleController extends AuthController { public Role update(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String id, @RequestBody Map<String, Object> body) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); boolean includeLegacy = this.userService.isSuperAdmin(client); Role current = this.roleService.get(client, graphSpace, id, includeLegacy); @@ -108,7 +108,7 @@ public class RoleController extends AuthController { @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String id) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.roleService.delete(client, graphSpace, id, this.userService.isSuperAdmin(client)); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java index a11368d11..a3d25e3c5 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java @@ -45,7 +45,7 @@ public class TargetController extends AuthController { @GetMapping("list") public List<Target> list(@PathVariable("graphspace") String graphSpace) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.targetService.list(client, graphSpace); } @@ -58,7 +58,7 @@ public class TargetController extends AuthController { defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.targetService.queryPage(client, graphSpace, query, pageNo, pageSize); } @@ -66,14 +66,14 @@ public class TargetController extends AuthController { @GetMapping("{id}") public Target get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.targetService.get(client, graphSpace, targetId); } @PostMapping public Target add(@PathVariable("graphspace") String graphSpace, @RequestBody Target target) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.targetService.add(client, graphSpace, target); } @@ -81,7 +81,7 @@ public class TargetController extends AuthController { public Target update(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId, @RequestBody Target target) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); Target current = this.targetService.get(client, graphSpace, targetId); current.resources(target.resources()); current.description(target.description()); @@ -91,7 +91,7 @@ public class TargetController extends AuthController { @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.targetService.delete(client, graphSpace, targetId); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java index b8c3bc712..fb1d8900a 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java @@ -49,6 +49,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Collections; import java.util.List; +import java.util.Map; @RestController @RequestMapping(Constant.API_VERSION + "graphspaces") @@ -101,6 +102,11 @@ public class GraphSpaceController extends BaseController { return ImmutableMap.of("records", Collections.emptyList(), "total", 0); } + if (this.authMode != null && this.authMode.anonymous()) { + HugeClient client = this.authClient(null, null); + List<Map<String, Object>> graphSpaces = this.graphSpaceService.queryAnonymousGs(client, query, createTime); + return all ? graphSpaces : PageUtil.page(graphSpaces, pageNo, pageSize); + } if (all) { HugeClient client = this.authClient(null, null); return this.userService.isSuperAdmin(client) ? @@ -137,6 +143,12 @@ public class GraphSpaceController extends BaseController { return this.graphSpaceService.toView(stub); } HugeClient client = this.authClient(null, null); + if (this.authMode != null && this.authMode.anonymous()) { + GraphSpaceEntity entity = GraphSpaceEntity.fromGraphSpace(this.graphSpaceService.getWithoutAdmins(client, + graphspace)); + entity.setStatistic(this.graphSpaceService.evCount(client, graphspace)); + return this.graphSpaceService.toView(entity); + } // Get GraphSpace Info return graphSpaceService.toView( graphSpaceService.getWithAdmins(client, graphspace)); 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 2bdc11f5a..9db4a23a4 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 @@ -19,6 +19,8 @@ package org.apache.hugegraph.handler; import java.util.regex.Pattern; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -61,6 +63,9 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { validatePage(request, "page_no", false); validatePage(request, "page_size", true); String url = request.getRequestURI(); + if (url.endsWith("/config")) { + return true; + } if (!CHECK_API_PATTERN.matcher(url).matches()) { setHugeClientToRequest(request); return true; @@ -122,24 +127,20 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { if (this.isLogoutRequest(uri)) { return; } - if (this.authMode.anonymous()) { - client = unauthClient(); + if (this.authMode != null && this.authMode.anonymous() && + uri.endsWith("/auth/status")) { + return; + } + String[] scope = this.requestScope(uri); + String graphSpace = scope[0]; + String graph = scope[1]; + if (this.authMode != null && this.authMode.anonymous()) { + client = unauthClient(graphSpace, graph); } else if (!this.hasAuthSession(request)) { return; } 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); } } @@ -180,4 +181,33 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { protected HugeClient unauthClient() { return this.hugeClientPoolService.createUnauthClient(); } + + protected HugeClient unauthClient(String graphSpace, String graph) { + return this.hugeClientPoolService.createUnauthClient(graphSpace, graph); + } + + private String[] requestScope(String uri) { + String graphSpace = null; + String graph = null; + String[] parts = uri.split("/"); + for (int i = 0; i < parts.length; i++) { + if ("graphspaces".equals(parts[i]) && i < parts.length - 1) { + graphSpace = parts[i + 1]; + graphSpace = decodeSegment(graphSpace); + } + if ("graphs".equals(parts[i]) && i < parts.length - 1) { + graph = parts[i + 1]; + graph = decodeSegment(graph); + } + } + return new String[]{graphSpace, graph}; + } + + private static String decodeSegment(String segment) { + try { + return URLDecoder.decode(segment, StandardCharsets.UTF_8.name()); + } catch (java.io.UnsupportedEncodingException ignored) { + return segment; + } + } } 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 779c65c62..5d49aa9f8 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,7 +20,9 @@ package org.apache.hugegraph.handler; import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.exception.UnauthorizedException; +import org.apache.hugegraph.exception.ExternalException; import org.apache.hugegraph.service.auth.AuthModeService; +import org.springframework.http.HttpStatus; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.StringUtils; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; @@ -41,6 +43,10 @@ public class LoginInterceptor extends HandlerInterceptorAdapter { return true; } if (this.authMode != null && this.authMode.anonymous()) { + if (isAnonymousAuthManagement(request.getRequestURI())) { + throw new ExternalException(HttpStatus.FORBIDDEN.value(), + "Authentication is disabled"); + } return true; } @@ -57,4 +63,14 @@ public class LoginInterceptor extends HandlerInterceptorAdapter { Object value = request.getSession().getAttribute(key); return value instanceof String && StringUtils.hasText((String) value); } + + private static boolean isAnonymousAuthManagement(String uri) { + if (uri.contains("/graphspaces/") && uri.contains("/auth/")) { + return true; + } + if (!uri.contains("/auth/")) { + return false; + } + return !uri.endsWith("/auth/context") && !uri.endsWith("/auth/status") && !uri.endsWith("/auth/logout"); + } } 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 7cb589d1a..3ae56c344 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 @@ -280,10 +280,8 @@ public class HubbleOptions extends OptionHolder { ); 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.", + new ConfigOption<>("auth.enabled", + "Whether Hubble requires a user session. Set false when " + "the connected HugeGraph Server runs in anonymous mode.", null, true ); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java index ccd3b7033..167f7f5c0 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java @@ -91,6 +91,10 @@ public final class HugeClientPoolService { return getOrCreate(null, null, null, null); } + public HugeClient createUnauthClient(String graphSpace, String graph) { + return getOrCreate(null, graphSpace, graph, null); + } + public HugeClient createTempTokenClient(String token) { return getOrCreate(null, null, null, token); } 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 b141b55de..11cd95d8e 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,7 +87,8 @@ public class AuthContextService { } public Map<String, Object> context(HugeClient client, String username) { - if (!this.config.get(HubbleOptions.AUTH_ENABLED)) { + if (Boolean.FALSE.equals( + this.config.get(HubbleOptions.AUTH_ENABLED))) { return anonymousContext(this.config.get(HubbleOptions.PD_ENABLED)); } boolean pdEnabled = this.config.get(HubbleOptions.PD_ENABLED); @@ -136,10 +137,8 @@ public class AuthContextService { 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) : + 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<>(); @@ -166,6 +165,8 @@ public class AuthContextService { if (pdEnabled && (SUPERADMIN.equals(role) || SPACEADMIN.equals(role))) { capabilities.add(GRAPHSPACE_MEMBERS_MANAGE); + } + if (pdEnabled && SUPERADMIN.equals(role)) { capabilities.add(GRAPHSPACE_ROLES_MANAGE); capabilities.add(GRAPHSPACE_AUTHORIZATIONS_MANAGE); } @@ -185,8 +186,8 @@ public class AuthContextService { actions.put("graphspaces", pdEnabled ? (superAdmin ? CRUD_ACTIONS : set("read")) : emptySet()); actions.put("members", spaceManager ? MEMBER_ACTIONS : emptySet()); - actions.put("roles", spaceManager ? CRUD_ACTIONS : emptySet()); - actions.put("authorizations", spaceManager ? + actions.put("roles", superAdmin ? CRUD_ACTIONS : emptySet()); + actions.put("authorizations", superAdmin ? AUTHORIZATION_ACTIONS : emptySet()); actions.put("operations", superAdmin ? OPERATIONS_ACTIONS : emptySet()); 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 index 9500b709d..f4f8c8230 100644 --- 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 @@ -38,7 +38,7 @@ public final class AuthModeService { } public boolean enabled() { - return this.config.get(HubbleOptions.AUTH_ENABLED); + return !Boolean.FALSE.equals(this.config.get(HubbleOptions.AUTH_ENABLED)); } public boolean anonymous() { 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 ad8a669fa..225a1b535 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,9 +20,7 @@ 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; @@ -35,7 +33,6 @@ 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; @@ -48,11 +45,9 @@ 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<>(); + Map<String, UserView> users = new java.util.LinkedHashMap<>(); List<BelongEntity> belongs = this.belongService.list( client, graphSpace, null, null); @@ -69,9 +64,22 @@ public class GraphSpaceUserService extends AuthService { user.addRole(new RoleEntity(belong.getRoleId(), belong.getRoleName())); }); - users.add(user); + users.put(userId, user); }); - return users; + client.auth().listSpaceMember(graphSpace).forEach(username -> { + User account = client.findUserByName(username); + if (account == null) { + return; + } + String userId = account.id().toString(); + UserView user = users.computeIfAbsent(userId, + id -> new UserView(id, username, new ArrayList<>())); + if (client.supportsDefaultRole()) { + this.addDefaultRole(client, graphSpace, user, username, "observer"); + this.addDefaultRole(client, graphSpace, user, username, "analyst"); + } + }); + return new ArrayList<>(users.values()); } public UserView getUser(HugeClient client, String graphSpace, @@ -86,6 +94,19 @@ public class GraphSpaceUserService extends AuthService { user.addRole(new RoleEntity(belong.getRoleId(), belong.getRoleName())); }); + if (user.getId() == null) { + User account = client.auth().getUser(userId); + if (account != null) { + user.setId(account.id().toString()); + user.setName(account.name()); + if (client.supportsDefaultRole()) { + this.addDefaultRole(client, graphSpace, user, + account.name(), "observer"); + this.addDefaultRole(client, graphSpace, user, + account.name(), "analyst"); + } + } + } return user; } @@ -147,82 +168,151 @@ public class GraphSpaceUserService extends AuthService { if (preset == null || "SUPER_ADMIN".equals(preset)) { return; } - User account = client.auth().getUserByName(username); + E.checkArgument(client.supportsDefaultRole(), "Permission presets require HugeGraph Server 1.8+"); + User account = client.findUserByName(username); if (account == null) { return; } - Map<String, String> desired = new HashMap<>(); - List<Map<String, String>> requested = permissions == null ? - new ArrayList<>() : permissions; + Map<String, String> desired = new java.util.LinkedHashMap<>(); + 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); + desired.put(graphSpace, permissionPreset); } } 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 desiredPreset = desired.get(graphSpace); + if (desiredPreset == null) { + this.unauthUser(client, graphSpace, + account.id().toString()); + } else { + this.applySpacePreset(client, graphSpace, + account.id().toString(), + desiredPreset); } - 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; + } + } + + public void validatePermissionPresets( + HugeClient client, List<Map<String, String>> permissions, + String preset) { + if (preset == null || "SUPER_ADMIN".equals(preset)) { + return; + } + E.checkArgument(client.supportsDefaultRole(), "Permission presets require HugeGraph Server 1.8+"); + Set<String> graphSpaces = + new java.util.HashSet<>(client.graphSpace().listGraphSpace()); + for (Map<String, String> permission : + permissions == null ? new ArrayList<Map<String, String>>() : permissions) { + String graphSpace = permission.get("graphspace"); + String permissionPreset = permission.get("permission_preset"); + E.checkArgument(graphSpace != null && graphSpaces.contains(graphSpace), + "The graphspace does not exist: %s", graphSpace); + E.checkArgument("GS_READ_ONLY".equals(permissionPreset) || + "GS_READ_WRITE".equals(permissionPreset) || "GS_ADMIN".equals(permissionPreset), + "Unsupported permission preset: %s", + permissionPreset); + } + } + + public void applySpacePreset(HugeClient client, String graphSpace, + String userId, String preset) { + E.checkArgument("GS_READ_ONLY".equals(preset) || "GS_READ_WRITE".equals(preset) || "GS_ADMIN".equals(preset), + "Unsupported permission preset: %s", preset); + E.checkArgument(client.supportsDefaultRole(), + "Permission presets require HugeGraph Server 1.8+"); + User account = client.auth().getUser(userId); + E.checkNotNull(account, "User"); + this.clearCustomRoles(client, graphSpace, userId); + this.clearDefaultRoles(client, graphSpace, account.name()); + if (!client.auth().listSpaceMember(graphSpace) + .contains(account.name())) { + client.auth().addSpaceMember(account.name(), graphSpace); + } + if ("GS_ADMIN".equals(preset)) { + if (!client.auth().listSpaceAdmin(graphSpace) + .contains(account.name())) { + client.auth().addSpaceAdmin(account.name(), graphSpace); } - 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); + this.setDefaultRole(client, graphSpace, account.name(), "analyst"); + return; } + if (client.auth().listSpaceAdmin(graphSpace).contains(account.name())) { + client.auth().delSpaceAdmin(account.name(), graphSpace); + } + String role = "GS_READ_ONLY".equals(preset) ? "observer" : "analyst"; + this.setDefaultRole(client, graphSpace, account.name(), role); + } + + public void removeSpacePreset(HugeClient client, String graphSpace, + String userId) { + this.unauthUser(client, graphSpace, userId); } - 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)); + public boolean hasCustomRoles(HugeClient client, String graphSpace, + String userId) { + return !this.belongService.list(client, graphSpace, null, userId).isEmpty(); } - private static boolean isPresetRole(RoleEntity role) { - String name = role.getName() == null ? role.getId() : - role.getName(); - if (name == null) { + public boolean hasGraphSpaceAccess(HugeClient client, String graphSpace, + String username) { + if (!client.supportsDefaultRole()) { return false; } - String normalized = name.toLowerCase(Locale.ROOT); - return "observer".equals(normalized) || - "analyst".equals(normalized); + if (client.graphSpace().checkDefaultRole( + graphSpace, username, "analyst")) { + return true; + } + return this.graphs(client, graphSpace).stream().anyMatch( + graph -> client.graphSpace().checkDefaultRole(graphSpace, username, "observer", graph)); + } + + private void clearDefaultRoles(HugeClient client, String graphSpace, + String username) { + if (client.graphSpace().checkDefaultRole( + graphSpace, username, "analyst")) { + client.graphSpace().deleteDefaultRole(graphSpace, username, "analyst"); + } + for (String graph : this.graphs(client, graphSpace)) { + if (client.graphSpace().checkDefaultRole( + graphSpace, username, "observer", graph)) { + client.graphSpace().deleteDefaultRole(graphSpace, username, "observer", graph); + } + } + } + + private void clearCustomRoles(HugeClient client, String graphSpace, + String userId) { + this.belongService.list(client, graphSpace, null, userId) + .forEach(belong -> this.belongService.deleteById(client, graphSpace, belong.getId())); + } + + private void setDefaultRole(HugeClient client, String graphSpace, + String username, String role) { + if ("observer".equals(role)) { + for (String graph : this.graphs(client, graphSpace)) { + client.graphSpace().setDefaultRole(graphSpace, username, role, graph); + } + return; + } + client.graphSpace().setDefaultRole(graphSpace, username, role); + } + + private void addDefaultRole(HugeClient client, String graphSpace, + UserView user, String username, String role) { + boolean assigned = "observer".equals(role) ? this.graphs(client, graphSpace).stream().anyMatch( + graph -> client.graphSpace().checkDefaultRole(graphSpace, username, role, graph)) : + client.graphSpace().checkDefaultRole(graphSpace, username, role); + if (assigned) { + user.addRole(new RoleEntity(role, role)); + } + } + + private List<String> graphs(HugeClient client, String graphSpace) { + client.assignGraph(graphSpace, ""); + return client.graphs().listGraph(); } public void unauthUser(HugeClient client, String graphSpace, @@ -231,11 +321,18 @@ public class GraphSpaceUserService extends AuthService { E.checkNotNull(account, "User"); List<BelongEntity> belongs = this.belongService.list( client, graphSpace, null, userId); - E.checkState(!belongs.isEmpty(), "The user: (%s) not exists", userId); belongs.forEach(belong -> { this.belongService.deleteById(client, graphSpace, belong.getId()); }); - client.auth().delSpaceMember(account.name(), graphSpace); + if (client.supportsDefaultRole()) { + this.clearDefaultRoles(client, graphSpace, account.name()); + } + if (client.auth().listSpaceAdmin(graphSpace).contains(account.name())) { + client.auth().delSpaceAdmin(account.name(), graphSpace); + } + if (client.auth().listSpaceMember(graphSpace).contains(account.name())) { + client.auth().delSpaceMember(account.name(), graphSpace); + } } public IPage<User> querySpaceAdmins(HugeClient client, String graphSpace, @@ -253,7 +350,10 @@ public class GraphSpaceUserService extends AuthService { List<String> spaceAdmins = client.auth().listSpaceAdmin(graphSpace); ArrayList<User> users = new ArrayList<>(); for (String spaceAdmin : spaceAdmins) { - users.add(client.auth().getUser(spaceAdmin)); + User user = client.findUserByName(spaceAdmin); + if (user != null) { + users.add(user); + } } return users; } 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 6e696705f..4e8153a4d 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 @@ -26,7 +26,6 @@ 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; @@ -42,10 +41,9 @@ 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.E; import org.apache.hugegraph.util.HubbleUtil; import org.apache.hugegraph.util.PageUtil; @@ -105,7 +103,7 @@ public class UserService extends AuthService { } public UserEntity getUser(HugeClient client, String name) { - return convert(client, client.auth().getUserByName(name)); + return convert(client, client.findUserByName(name)); } public Object queryPage(HugeClient hugeClient, String query, @@ -159,9 +157,13 @@ public class UserService extends AuthService { Map<String, List<String>> spaceMap = HubbleUtil.uncheckedCast(listMap.get(0)); List<String> adminSpaces = spaceMap.get(userId); + if (adminSpaces == null) { + adminSpaces = new ArrayList<>(); + } List<String> resSpaces = new ArrayList<>(); for (String space : spaces) { - if (hugeClient.graphSpace().checkDefaultRole(space, userId, "analyst")) { + if (this.graphSpaceUserService.hasGraphSpaceAccess( + hugeClient, space, user.name())) { resSpaces.add(space); } } @@ -180,8 +182,7 @@ public class UserService extends AuthService { } public UserEntity getpersonal(HugeClient hugeClient, String username) { - AuthManager auth = hugeClient.auth(); - User user = auth.getUserByName(username); + User user = hugeClient.findUserByName(username); if (user == null) { throw new InternalException("auth.user.get.%s Not Exits", username); @@ -197,7 +198,7 @@ public class UserService extends AuthService { adminSpaces.add(space); } if (hugeClient.auth().isSpaceAdmin(space) || - hugeClient.auth().checkDefaultRole(space, "analyst")) { + hasCurrentUserAccess(hugeClient, space)) { resSpaces.add(space); } } @@ -215,6 +216,10 @@ public class UserService extends AuthService { } public void add(HugeClient client, UserEntity ue) { + if (isPdEnabled()) { + this.graphSpaceUserService.validatePermissionPresets(client, ue.getGraphspacePermissions(), + ue.getPermissionPreset()); + } User user = new User(); user.name(ue.getName()); user.password(ue.getPassword()); @@ -233,8 +238,7 @@ public class UserService extends AuthService { } } if (isPdEnabled()) { - this.graphSpaceUserService.applyPermissionPresets( - client, ue.getName(), ue.getGraphspacePermissions(), + this.graphSpaceUserService.applyPermissionPresets(client, ue.getName(), ue.getGraphspacePermissions(), ue.getPermissionPreset()); } @@ -364,68 +368,110 @@ public class UserService extends AuthService { private void populatePermissionPresets(HugeClient client, UserEntity userEntity) { List<Map<String, String>> permissions = new ArrayList<>(); + if (userEntity.isSuperadmin()) { + userEntity.setGraphspacePermissions(permissions); + userEntity.setPermissionPreset("SUPER_ADMIN"); + return; + } + if (!client.supportsDefaultRole()) { + userEntity.setGraphspacePermissions(permissions); + userEntity.setPermissionPreset("LEGACY_CUSTOM"); + return; + } List<String> graphSpaces = client.graphSpace().listGraphSpace(); + boolean legacyCustom = false; for (String graphSpace : graphSpaces) { + legacyCustom |= this.graphSpaceUserService.hasCustomRoles(client, graphSpace, userEntity.getId()); 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")); - } - }); + if (client.graphSpace().checkDefaultRole( + graphSpace, userEntity.getName(), "analyst")) { + permissions.add(permission(graphSpace, "GS_READ_WRITE")); + } else if (hasObserverRole(client, graphSpace, userEntity.getName())) { + permissions.add(permission(graphSpace, "GS_READ_ONLY")); + } } userEntity.setGraphspacePermissions(permissions); + if (legacyCustom) { + userEntity.setPermissionPreset("LEGACY_CUSTOM"); + } } 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)); - } - } + if (!client.supportsDefaultRole()) { + for (UserEntity user : users) { + user.setGraphspacePermissions(new ArrayList<>()); + user.setPermissionPreset(user.isSuperadmin() ? "SUPER_ADMIN" : "LEGACY_CUSTOM"); } + return; } + List<String> graphSpaces = client.graphSpace().listGraphSpace(); for (UserEntity user : users) { - List<Map<String, String>> values = permissions.getOrDefault( - user.getId(), new ArrayList<>()); + List<Map<String, String>> values = new ArrayList<>(); + if (user.isSuperadmin()) { + user.setGraphspacePermissions(values); + user.setPermissionPreset("SUPER_ADMIN"); + continue; + } + boolean legacyCustom = false; if (user.getAdminSpaces() != null) { for (String graphSpace : user.getAdminSpaces()) { values.add(permission(graphSpace, "GS_ADMIN")); } } + for (String graphSpace : graphSpaces) { + legacyCustom |= this.graphSpaceUserService.hasCustomRoles(client, graphSpace, user.getId()); + if (user.getAdminSpaces() != null && + user.getAdminSpaces().contains(graphSpace)) { + continue; + } + if (client.graphSpace().checkDefaultRole( + graphSpace, user.getName(), "analyst")) { + values.add(permission(graphSpace, "GS_READ_WRITE")); + } else if (hasObserverRole(client, graphSpace, user.getName())) { + values.add(permission(graphSpace, "GS_READ_ONLY")); + } + } user.setGraphspacePermissions(values); + if (legacyCustom) { + user.setPermissionPreset("LEGACY_CUSTOM"); + } } } private static Map<String, String> permission(String graphSpace, - String preset) { + String preset) { Map<String, String> permission = new HashMap<>(); permission.put("graphspace", graphSpace); permission.put("permission_preset", preset); return permission; } + private static boolean hasObserverRole(HugeClient client, + String graphSpace, + String username) { + client.assignGraph(graphSpace, ""); + return client.graphs().listGraph().stream().anyMatch( + graph -> client.graphSpace().checkDefaultRole(graphSpace, username, "observer", graph)); + } + + private static boolean hasCurrentUserAccess(HugeClient client, + String graphSpace) { + if (!client.supportsDefaultRole()) { + return false; + } + if (client.auth().checkDefaultRole(graphSpace, "analyst")) { + return true; + } + client.assignGraph(graphSpace, ""); + return client.graphs().listGraph().stream().anyMatch( + graph -> client.auth().checkDefaultRole(graphSpace, "observer", graph)); + } + protected List<Object> getSpaceAndSpacenum(HugeClient hugeClient) { AuthManager auth = hugeClient.auth(); List<Object> listMap = new ArrayList<>(); @@ -459,6 +505,10 @@ public class UserService extends AuthService { } public void update(HugeClient hugeClient, UserEntity userEntity) { + if (isPdEnabled()) { + this.graphSpaceUserService.validatePermissionPresets(hugeClient, userEntity.getGraphspacePermissions(), + userEntity.getPermissionPreset()); + } User user = new User(); user.setId(userEntity.getId()); user.name(userEntity.getName()); @@ -482,8 +532,7 @@ public class UserService extends AuthService { hugeClient.auth().updateUser(user); if (isPdEnabled()) { - this.graphSpaceUserService.applyPermissionPresets( - hugeClient, userEntity.getName(), + this.graphSpaceUserService.applyPermissionPresets(hugeClient, userEntity.getName(), userEntity.getGraphspacePermissions(), userEntity.getPermissionPreset()); } @@ -491,8 +540,7 @@ public class UserService extends AuthService { public void updatePersonal(HugeClient hugeClient, String username, String nickname, String description) { - AuthManager auth = hugeClient.auth(); - User user = auth.getUserByName(username); + User user = hugeClient.findUserByName(username); if (isPdEnabled()) { user.nickname(nickname); } else { @@ -519,7 +567,7 @@ public class UserService extends AuthService { } // Must fetch user first to get the ID, otherwise updateUser sends // PUT to the collection path (no {id}) and gets HTTP 405. - User user = hugeClient.auth().getUserByName(username); + User user = hugeClient.findUserByName(username); user.password(newpwd); hugeClient.auth().updateUser(user); return Response.builder() @@ -531,8 +579,6 @@ public class UserService extends AuthService { if (!isPdEnabled()) { return new ArrayList<>(); } - AuthManager auth = hugeClient.auth(); - List<User> users = auth.listUsers(); List<String> spaces = hugeClient.graphSpace().listGraphSpace(); List<String> adminspace = new ArrayList<String>(); for (String space : spaces) { @@ -553,14 +599,31 @@ public class UserService extends AuthService { return; } List<String> oldadminspaces = listAdminSpace(hugeClient, username); + User account = hugeClient.findUserByName(username); + E.checkNotNull(account, "User"); + if (!hugeClient.supportsDefaultRole()) { + for (String adminspace : adminspaces) { + if (!oldadminspaces.contains(adminspace)) { + hugeClient.auth().addSpaceAdmin(username, adminspace); + } + } + for (String oldadminspace : oldadminspaces) { + if (!adminspaces.contains(oldadminspace)) { + hugeClient.auth().delSpaceAdmin(username, oldadminspace); + } + } + return; + } for (String adminspace : adminspaces) { if (!oldadminspaces.contains(adminspace)) { - hugeClient.auth().addSpaceAdmin(username, adminspace); + this.graphSpaceUserService.applySpacePreset(hugeClient, adminspace, account.id().toString(), + "GS_ADMIN"); } } for (String oldadminspace : oldadminspaces) { if (!adminspaces.contains(oldadminspace)) { - hugeClient.auth().delSpaceAdmin(username, oldadminspace); + this.graphSpaceUserService.removeSpacePreset(hugeClient, oldadminspace, + account.id().toString()); } } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java index b5b85dca5..ebd45a05b 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java @@ -159,8 +159,46 @@ public class GraphSpaceService { space.getCreateTime().compareTo(after) > 0) .filter(space -> !space.isAuth() || client.auth().isSpaceAdmin(space.getName()) || - client.auth().checkDefaultRole( - space.getName(), "analyst")) + hasCurrentUserAccess(client, space.getName())) + .map(space -> { + GraphSpaceEntity entity = + GraphSpaceEntity.fromGraphSpace(space); + entity.setStatistic(evCount(client, space.getName())); + Map<String, Object> info = toView(entity); + info.put("authed", true); + info.put("default", false); + return info; + }) + .collect(Collectors.toList()); + Collections.sort(results, (a, b) -> + new BuiltInFirst().compare(a.get("name").toString(), + b.get("name").toString())); + return results; + } + + private static boolean hasCurrentUserAccess(HugeClient client, + String graphSpace) { + if (!client.supportsDefaultRole()) { + return false; + } + if (client.auth().checkDefaultRole(graphSpace, "analyst")) { + return true; + } + client.assignGraph(graphSpace, ""); + return client.graphs().listGraph().stream().anyMatch( + graph -> client.auth().checkDefaultRole(graphSpace, "observer", graph)); + } + + public List<Map<String, Object>> queryAnonymousGs(HugeClient client, + String query, + String createTime) { + String prefix = query == null ? "" : query; + String after = createTime == null ? "" : createTime; + List<Map<String, Object>> results = client.graphSpace() + .listGraphSpace().stream() + .map(client.graphSpace()::getGraphSpace).filter(space -> space != null && (space.getName().contains(prefix) || + space.getNickname() != null && space.getNickname().contains(prefix))) + .filter(space -> space.getCreateTime() == null || space.getCreateTime().compareTo(after) > 0) .map(space -> { GraphSpaceEntity entity = GraphSpaceEntity.fromGraphSpace(space); @@ -200,7 +238,7 @@ public class GraphSpaceService { * @param graphSpace * @return */ - Map<String, Object> evCount(HugeClient client, String graphSpace) { + public Map<String, Object> evCount(HugeClient client, String graphSpace) { Long vertexTotal = 0L; Long edgeTotal = 0L; Map<String, Object> statisticTotal = new HashMap<>(); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java index 177b04147..9cf151c9e 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java @@ -140,7 +140,7 @@ public class GraphSpaceAuthMutationAuthorizationTest { } @Test - public void testCurrentSpaceAdminCanManageScopedAuthorizationResources() + public void testCurrentSpaceAdminCanManageMembersOnly() throws Exception { Mockito.when(this.authorizationService.isAssignSpaceAdmin( this.client, "SPACE")) @@ -149,8 +149,11 @@ public class GraphSpaceAuthMutationAuthorizationTest { Mockito.eq(this.client), Mockito.any())) .thenReturn("SPACEADMIN"); - this.assertScopedReadsAllowed(); - this.assertScopedCreatesAllowed(); + this.assertLowLevelReadsForbidden(); + ReadRoute members = this.memberReadRoute(); + mvc(members.controller).perform(members.request) + .andExpect(status().isOk()); + this.assertScopedCreatesForbidden(); } @Test @@ -191,6 +194,12 @@ public class GraphSpaceAuthMutationAuthorizationTest { } } + private void assertLowLevelReadsForbidden() throws Exception { + for (ReadRoute route : this.lowLevelReadRoutes()) { + assertForbidden(mvc(route.controller), route.request); + } + } + private void assertScopedReadsForbidden() throws Exception { for (ReadRoute route : this.scopedReadRoutes()) { assertForbidden(mvc(route.controller), route.request); @@ -198,6 +207,14 @@ public class GraphSpaceAuthMutationAuthorizationTest { } private ReadRoute[] scopedReadRoutes() { + ReadRoute[] lowLevel = this.lowLevelReadRoutes(); + return new ReadRoute[]{ + lowLevel[0], lowLevel[1], lowLevel[2], lowLevel[3], + this.memberReadRoute() + }; + } + + private ReadRoute[] lowLevelReadRoutes() { return new ReadRoute[]{ new ReadRoute(this.prepare(new TestBelongController( this.client), "belongService", @@ -214,12 +231,15 @@ public class GraphSpaceAuthMutationAuthorizationTest { new ReadRoute(this.prepare(new TestTargetController( this.client), "targetService", Mockito.mock(TargetService.class)), - get("/api/v1.3/graphspaces/SPACE/auth/targets")), - new ReadRoute(this.prepare(new TestGraphSpaceUserController( + get("/api/v1.3/graphspaces/SPACE/auth/targets")) + }; + } + + private ReadRoute memberReadRoute() { + return new ReadRoute(this.prepare(new TestGraphSpaceUserController( this.client), "userService", Mockito.mock(GraphSpaceUserService.class)), - get("/api/v1.3/graphspaces/SPACE/auth/users")) - }; + get("/api/v1.3/graphspaces/SPACE/auth/users")); } private void assertScopedCreatesAllowed() throws Exception { @@ -242,6 +262,24 @@ public class GraphSpaceAuthMutationAuthorizationTest { .andExpect(status().isOk()); } + private void assertScopedCreatesForbidden() throws Exception { + BelongController belong = this.prepare(new TestBelongController( + this.client), "belongService", Mockito.mock(BelongService.class)); + AccessController access = this.prepare(new TestAccessController( + this.client), "accessService", Mockito.mock(AccessService.class)); + TargetController target = this.prepare(new TestTargetController( + this.client), "targetService", Mockito.mock(TargetService.class)); + + assertForbidden(mvc(belong), + post("/api/v1.3/graphspaces/SPACE/auth/belongs") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role_id\":\"r\",\"user_id\":\"u\"}")); + assertForbidden(mvc(access), + post("/api/v1.3/graphspaces/SPACE/auth/accesses").contentType(MediaType.APPLICATION_JSON).content("{}")); + assertForbidden(mvc(target), + post("/api/v1.3/graphspaces/SPACE/auth/targets").contentType(MediaType.APPLICATION_JSON).content("{}")); + } + private <T extends BaseController> T prepare(T controller, String serviceField, Object service) { diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java index 4516f1286..c8aa3634f 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java @@ -35,6 +35,8 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.apache.hugegraph.controller.BaseController; import org.apache.hugegraph.driver.AuthManager; +import org.apache.hugegraph.driver.GraphSpaceManager; +import org.apache.hugegraph.driver.GraphsManager; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.AccessEntity; import org.apache.hugegraph.entity.auth.BelongEntity; @@ -65,12 +67,24 @@ public class GraphSpaceAuthOwnershipTest { private HugeClient client; private AuthManager auth; + private GraphSpaceManager graphSpace; + private GraphsManager graphs; @Before public void setup() { this.client = Mockito.mock(HugeClient.class); this.auth = Mockito.mock(AuthManager.class); + this.graphSpace = Mockito.mock(GraphSpaceManager.class); + this.graphs = Mockito.mock(GraphsManager.class); Mockito.when(this.client.auth()).thenReturn(this.auth); + Mockito.when(this.client.graphSpace()).thenReturn(this.graphSpace); + Mockito.when(this.client.graphs()).thenReturn(this.graphs); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(this.graphs.listGraph()).thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceAdmin(Mockito.anyString())) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceMember(Mockito.anyString())) + .thenReturn(Collections.emptyList()); } @Test @@ -451,14 +465,15 @@ public class GraphSpaceAuthOwnershipTest { public void testGraphSpaceUserRemovalDeletesOnlyScopedBelongs() { BelongService belongs = Mockito.mock(BelongService.class); User account = new User(); + account.setId("user-id"); account.name("graph-user"); Mockito.when(this.auth.getUser("user-id")).thenReturn(account); + Mockito.when(this.auth.listSpaceMember("SPACE_A")).thenReturn(Collections.singletonList("graph-user")); BelongEntity scoped = BelongEntity.builder() .id("belong-a") .userId("user-id") .build(); - Mockito.when(belongs.list(this.client, "SPACE_A", null, "user-id")) - .thenReturn(Collections.singletonList(scoped)); + Mockito.when(belongs.list(this.client, "SPACE_A", null, "user-id")).thenReturn(Collections.singletonList(scoped)); GraphSpaceUserService service = new GraphSpaceUserService(); ReflectionTestUtils.setField(service, "belongService", belongs); @@ -471,6 +486,27 @@ public class GraphSpaceAuthOwnershipTest { Mockito.verify(this.auth).delSpaceMember("graph-user", "SPACE_A"); } + @Test + public void testReadOnlyPresetAppliesObserverToEveryGraph() { + User account = new User(); + account.setId("user-id"); + account.name("graph-user"); + Mockito.when(this.auth.getUser("user-id")).thenReturn(account); + Mockito.when(this.graphs.listGraph()) + .thenReturn(Arrays.asList("graph-1", "graph-2")); + BelongService belongs = Mockito.mock(BelongService.class); + Mockito.when(belongs.list(this.client, "SPACE_A", null, "user-id")).thenReturn(Collections.emptyList()); + GraphSpaceUserService service = new GraphSpaceUserService(); + ReflectionTestUtils.setField(service, "belongService", belongs); + + service.applySpacePreset(this.client, "SPACE_A", "user-id", "GS_READ_ONLY"); + + Mockito.verify(this.auth).addSpaceMember("graph-user", "SPACE_A"); + Mockito.verify(this.graphSpace).setDefaultRole("SPACE_A", "graph-user", "observer", "graph-1"); + Mockito.verify(this.graphSpace).setDefaultRole("SPACE_A", "graph-user", "observer", "graph-2"); + Mockito.verify(this.graphSpace, Mockito.never()).setDefaultRole("SPACE_A", "graph-user", "observer"); + } + @Test public void testGraphSpaceUserRoleUpdatePreflightsAllRoles() { List<Group> groups = this.createScopedGroups("SPACE_A", "SPACE_B"); @@ -500,6 +536,7 @@ public class GraphSpaceAuthOwnershipTest { Mockito.when(this.auth.getGraphSpaceGroup("local-role")) .thenReturn(group); User account = new User(); + account.setId("user-id"); account.name("graph-user"); Mockito.when(this.auth.getUser("user-id")).thenReturn(account); Mockito.when(this.auth.listSpaceMember("SPACE_A")) diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java index d5a68defb..cc4ba00df 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/AuthContextServiceTest.java @@ -86,8 +86,10 @@ public class AuthContextServiceTest { Assert.assertTrue(capabilities(context).contains( "graphspace_members_manage")); Assert.assertTrue(actions(context, "members").contains("add")); - Assert.assertTrue(actions(context, "roles").contains("update")); - Assert.assertTrue(actions(context, "authorizations").contains("grant")); + Assert.assertTrue(actions(context, "roles").isEmpty()); + Assert.assertTrue(actions(context, "authorizations").isEmpty()); + Assert.assertFalse(capabilities(context).contains("graphspace_roles_manage")); + Assert.assertFalse(capabilities(context).contains("graphspace_authorizations_manage")); Assert.assertEquals(Arrays.asList("space-a", "space-b"), scopes(context).get("admin_graphspaces")); Assert.assertFalse((Boolean) scopes(context).get("all_graphspaces")); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java index 2675fc1a1..64df7fce9 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java @@ -74,6 +74,7 @@ import org.apache.hugegraph.handler.LoginInterceptor; import org.apache.hugegraph.handler.MessageSourceHandler; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.auth.AuthContextService; +import org.apache.hugegraph.service.auth.AuthModeService; import org.apache.hugegraph.service.auth.LoginAttemptGuard; import org.apache.hugegraph.service.auth.UserService; import org.apache.hugegraph.structure.auth.Login; @@ -177,6 +178,40 @@ public class AuthSecurityTest { null)); } + @Test + public void testAnonymousModeBlocksAuthManagementButAllowsContext() { + LoginInterceptor interceptor = new LoginInterceptor(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)) + .thenReturn(false); + AuthModeService mode = new AuthModeService(config); + ReflectionTestUtils.setField(interceptor, "authMode", mode); + + MockHttpServletRequest users = new MockHttpServletRequest("GET", "/api/v1.3/auth/users"); + try { + interceptor.preHandle(users, new MockHttpServletResponse(), null); + Assert.fail("Expected anonymous auth management to be blocked"); + } catch (ExternalException forbidden) { + Assert.assertEquals(HttpStatus.FORBIDDEN.value(), + forbidden.status()); + } + + MockHttpServletRequest context = new MockHttpServletRequest("GET", "/api/v1.3/auth/context"); + Assert.assertTrue(interceptor.preHandle(context, new MockHttpServletResponse(), null)); + } + + @Test + public void testConfigBootstrapDoesNotCreateServerClient() + throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1.3/config"); + + Assert.assertTrue(interceptor.preHandle(request, new MockHttpServletResponse(), null)); + Assert.assertEquals(0, interceptor.authClients); + Assert.assertEquals(0, interceptor.unauthClients); + Assert.assertNull(request.getAttribute("hugeClient")); + } + @Test public void testCustomInterceptorDoesNotCreateClientForMissingSession() throws Exception { @@ -313,6 +348,22 @@ public class AuthSecurityTest { Assert.assertEquals("token", interceptor.token); } + @Test + public void testAnonymousClientUsesGraphSpaceScope() throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)) + .thenReturn(false); + AuthModeService mode = new AuthModeService(config); + ReflectionTestUtils.setField(interceptor, "authMode", mode); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1.3/graphspaces/SPACE/graphs/graph/schema"); + + Assert.assertTrue(interceptor.preHandle(request, new MockHttpServletResponse(), null)); + Assert.assertEquals(1, interceptor.unauthClients); + Assert.assertEquals("SPACE", interceptor.graphSpace); + Assert.assertEquals("graph", interceptor.graph); + } + @Test public void testCustomInterceptorDoesNotCreateClientForLogout() throws Exception { @@ -871,6 +922,15 @@ public class AuthSecurityTest { this.unauthClients++; return null; } + + @Override + protected org.apache.hugegraph.driver.HugeClient unauthClient( + String graphSpace, String graph) { + this.unauthClients++; + this.graphSpace = graphSpace; + this.graph = graph; + return null; + } } private static void setField(Object object, String name, Object value) diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java index ae0319de2..62570641a 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java @@ -32,6 +32,7 @@ import org.apache.hugegraph.driver.AuthManager; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.UserEntity; import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.auth.GraphSpaceUserService; import org.apache.hugegraph.service.auth.UserService; import org.apache.hugegraph.structure.auth.User; @@ -52,6 +53,8 @@ public class UserServiceCompatibilityTest { .thenReturn(new User()); this.service = new UserService(); ReflectionTestUtils.setField(this.service, "config", this.config); + ReflectionTestUtils.setField(this.service, "graphSpaceUserService", + Mockito.mock(GraphSpaceUserService.class)); } @Test @@ -111,6 +114,7 @@ public class UserServiceCompatibilityTest { Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); Mockito.when(this.auth.getUserByName("user")) .thenReturn(user("user")); + Mockito.when(this.client.findUserByName("user")).thenReturn(user("user")); this.service.updatePersonal(this.client, "user", "display-name", "description"); diff --git a/hugegraph-hubble/hubble-fe/src/App.test.js b/hugegraph-hubble/hubble-fe/src/App.test.js index 11805599a..a3ed5534d 100644 --- a/hugegraph-hubble/hubble-fe/src/App.test.js +++ b/hugegraph-hubble/hubble-fe/src/App.test.js @@ -19,15 +19,29 @@ import {render, screen} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import App from './App'; +import * as api from './api'; + +jest.mock('./api', () => ({ + config: { + getConfig: jest.fn(), + }, +})); jest.mock('./routes', () => ({element}) => ( <div data-testid="app-route">{element}</div> )); jest.mock('./layout.ant', () => () => <div>Hubble layout</div>); +jest.mock('./auth/AuthContext', () => ({ + AuthContextProvider: ({children}) => children, +})); -test('wires the Hubble layout into the application router', () => { +test('wires the Hubble layout into the application router', async () => { sessionStorage.clear(); + api.config.getConfig.mockResolvedValue({ + status: 200, + data: {pd_enabled: false, auth_enabled: false}, + }); render( <MemoryRouter future={{v7_startTransition: true, v7_relativeSplatPath: true}} @@ -35,6 +49,24 @@ test('wires the Hubble layout into the application router', () => { <App /> </MemoryRouter> ); - expect(screen.getByTestId('app-route')).toBeInTheDocument(); + expect(await screen.findByTestId('app-route')).toBeInTheDocument(); expect(screen.getByText('Hubble layout')).toBeInTheDocument(); + expect(JSON.parse(sessionStorage.getItem('hubble_config_'))).toEqual({pd_enabled: false, auth_enabled: false}); +}); + +test('shows a retry surface when configuration bootstrap fails', async () => { + api.config.getConfig.mockRejectedValue(new Error('offline')); + + render( + <MemoryRouter + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <App /> + </MemoryRouter> + ); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Unable to load Hubble configuration.' + ); + expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument(); }); diff --git a/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js b/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js index 8c435cbc6..aaef6bb25 100644 --- a/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js +++ b/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js @@ -105,6 +105,14 @@ describe('auth API contract', () => { it.each([ ['getSpaceMembers', ['A/B', {page_no: 1}], 'get', '/graphspaces/A%2FB/auth/users'], + ['getSpaceAdmins', ['A/B', {page_no: 1}], 'get', + '/graphspaces/A%2FB/auth/users/spaceadmin'], + ['setSpaceAdmin', ['A/B', 'u/1'], 'post-empty', + '/graphspaces/A%2FB/auth/users/spaceadmin/u%2F1'], + ['removeSpaceAdmin', ['A/B', 'u/1'], 'delete', + '/graphspaces/A%2FB/auth/users/spaceadmin/u%2F1'], + ['setSpacePreset', ['A/B', 'u/1', 'GS_READ_ONLY'], 'put-preset', + '/graphspaces/A%2FB/auth/users/u%2F1/preset'], ['addSpaceMember', ['A/B', {user_id: 'u'}], 'post', '/graphspaces/A%2FB/auth/users'], ['updateSpaceMember', ['A/B', 'u/1', {roles: []}], 'put', @@ -151,6 +159,16 @@ describe('auth API contract', () => { route, expectedParams, config ); } + else if (verb === 'post-empty') { + expect(mockRequest.post).toHaveBeenCalledWith( + route, undefined, config + ); + } + else if (verb === 'put-preset') { + expect(mockRequest.put).toHaveBeenCalledWith( + route, {permission_preset: args[2]}, config + ); + } else { expect(mockRequest[verb]).toHaveBeenCalledWith( route, args.at(-1), config diff --git a/hugegraph-hubble/hubble-fe/src/api/auth.js b/hugegraph-hubble/hubble-fe/src/api/auth.js index 18a5dee05..541c099f8 100644 --- a/hugegraph-hubble/hubble-fe/src/api/auth.js +++ b/hugegraph-hubble/hubble-fe/src/api/auth.js @@ -97,6 +97,27 @@ const getSpaceMembers = (graphspace, params, config = {}) => { return request.get(scopedAuthPath(graphspace, 'users'), {...config, params}); }; +const getSpaceAdmins = (graphspace, params, config = {}) => { + return request.get(scopedAuthPath(graphspace, 'users/spaceadmin'), + {...config, params}); +}; + +const setSpaceAdmin = (graphspace, id, config) => { + return request.post(scopedAuthPath(graphspace, 'users/spaceadmin', id), + undefined, config); +}; + +const removeSpaceAdmin = (graphspace, id, config) => { + return request.delete(scopedAuthPath(graphspace, 'users/spaceadmin', id), + undefined, config); +}; + +const setSpacePreset = (graphspace, id, preset, config) => { + return request.put(`${scopedAuthPath(graphspace, 'users', id)}/preset`, { + permission_preset: preset, + }, config); +}; + const addSpaceMember = (graphspace, data, config) => { return request.post(scopedAuthPath(graphspace, 'users'), data, config); }; @@ -161,6 +182,10 @@ const deleteSpaceAccess = (graphspace, roleId, targetId, config) => { export { getSpaceMembers, + getSpaceAdmins, + setSpaceAdmin, + removeSpaceAdmin, + setSpacePreset, addSpaceMember, updateSpaceMember, deleteSpaceMember, diff --git a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js index 6ed39db42..744fae2c0 100644 --- a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js +++ b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js @@ -30,7 +30,7 @@ import { ClusterOutlined, } from '@ant-design/icons'; import {Link, useLocation} from 'react-router-dom'; -import {isPdEnabled} from '../../utils/config'; +import {isAuthEnabled, isPdEnabled} from '../../utils/config'; import {getGraphspacePath} from '../../utils/productMode'; import {getPreparationSchemaPath} from '../../utils/dataPreparationNavigation'; import {getSidebarMenuKey} from '../../utils/sidebarNavigation'; @@ -46,11 +46,11 @@ const items = (t, pathname, capabilities = []) => { const ACCOUNT = {label: <Link to='/account'>{t('home.account')}</Link>, key: 'account'}; // TODO temporary hided the resource and role modules - let systemList = [MY]; + let systemList = isAuthEnabled() ? [MY] : []; if (capabilities.includes('accounts_manage') || capabilities.includes('graphspace_members_manage')) { // systemList = [MY, RESOURCE, ROLE]; - systemList = [MY, ACCOUNT]; + systemList = isAuthEnabled() ? [MY, ACCOUNT] : []; } const operationsList = [ ...(pdMode && capabilities.includes('operations_health_read') ? [{ diff --git a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js index 541015d68..db826c47f 100644 --- a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js +++ b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js @@ -73,6 +73,27 @@ test('hides PD-only operations links in standalone mode without topology access' expect(screen.queryByRole('link', {name: '节点详情'})).not.toBeInTheDocument(); }); +test('hides account and profile links in anonymous mode', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({ + pd_enabled: false, + auth_enabled: false, + })); + + render( + <MemoryRouter + initialEntries={['/navigation']} + future={{v7_startTransition: true, v7_relativeSplatPath: true}} + > + <Sidebar /> + </MemoryRouter> + ); + + expect(await screen.findByRole('navigation', {name: '主导航'})) + .toBeInTheDocument(); + expect(screen.queryByRole('link', {name: '个人中心'})).not.toBeInTheDocument(); + expect(screen.queryByRole('link', {name: '账号管理'})).not.toBeInTheDocument(); +}); + test('keeps monitoring and account links in one operations section', async () => { sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); render( 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 e8b92359a..eda395aec 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 @@ -391,7 +391,9 @@ "SUPER_ADMIN": "Super Administrator", "GS_READ_ONLY": "GraphSpace Read-only", "GS_READ_WRITE": "GraphSpace Read-write", - "GS_ADMIN": "GraphSpace Administrator" + "GS_ADMIN": "GraphSpace Administrator", + "mixed": "Mixed GraphSpace access", + "legacy_custom": "Legacy/custom access" }, "form": { "title_detail": "View Account", 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 da3e7f7e0..fa025bb7e 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 @@ -391,7 +391,9 @@ "SUPER_ADMIN": "超级管理员", "GS_READ_ONLY": "GraphSpace 只读", "GS_READ_WRITE": "GraphSpace 读写", - "GS_ADMIN": "GraphSpace 管理员" + "GS_ADMIN": "GraphSpace 管理员", + "mixed": "混合 GraphSpace 权限", + "legacy_custom": "旧版/自定义权限" }, "form": { "title_detail": "查看账号", diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js index 8d5d43292..775f23587 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js @@ -32,8 +32,7 @@ import { 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 permissionPresetChanged = (prev, next) => (prev.permission_preset !== next.permission_preset ); const HelpLabel = ({t, labelKey}) => ( @@ -79,8 +78,7 @@ const EditLayer = ({ }).catch(() => message.error(t('common.msg.operation_failed'))); }, [onCancel, refresh, t]); const updateUser = useCallback(values => { - return api.auth.updateUser( - data.id, toPermissionPayload(values), PAGE_ERROR_CONFIG + return api.auth.updateUser(data.id, toPermissionPayload(values), PAGE_ERROR_CONFIG ).then(res => { if (res.status === 200) { message.success(t('common.msg.update_success')); @@ -99,8 +97,7 @@ const EditLayer = ({ ...values, permission_preset: PERMISSION_PRESETS.GS_ADMIN, }); - return api.auth.updateAdminspace( - data.id, payload.adminSpaces, PAGE_ERROR_CONFIG + return api.auth.updateAdminspace(data.id, payload.adminSpaces, PAGE_ERROR_CONFIG ).then(res => { if (res.status === 200) { message.success(t('common.msg.set_success')); @@ -195,7 +192,9 @@ const EditLayer = ({ form.setFieldsValue({ ...res.data, permission_preset: getAccountPreset(res.data), - graphspaces: getPresetSpaces(res.data), + graphspaces: op === 'auth' + ? (res.data?.adminSpaces ?? []) + : getPresetSpaces(res.data), }); } setDetail(res.data); @@ -253,14 +252,12 @@ const EditLayer = ({ <Form.Item label={t('account.form.name')} className={style.item}> {detail.user_nickname} </Form.Item> - <Form.Item label={t('account.form.permission_preset')} className={style.item}> - {t(`account.permission_preset.${getAccountPreset(detail)}`)} + <Form.Item label={t('account.form.permission_preset')} className={style.item}> {t(`account.permission_preset.${getAccountPreset(detail) ?? 'mixed'}`)} </Form.Item> <Form.Item label={t('account.form.remark')} className={style.item}> {detail.user_description} </Form.Item> - <Form.Item label={t('account.form.graphspaces')} className={style.item}> - {getPresetSpaces(detail).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} @@ -341,8 +338,7 @@ const EditLayer = ({ noStyle shouldUpdate={permissionPresetChanged} > - {({getFieldValue}) => (getFieldValue('permission_preset') - !== PERMISSION_PRESETS.GS_ADMIN ? null : ( + {({getFieldValue}) => (getFieldValue('permission_preset') === PERMISSION_PRESETS.SUPER_ADMIN ? null : ( <Form.Item label={<HelpLabel t={t} labelKey='account.form.graphspaces' />} name="graphspaces" diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js index cd537c444..d6eb374ee 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js @@ -40,6 +40,31 @@ import {PERMISSION_PRESETS} from './permissionPresets'; const PAGE_ERROR_CONFIG = {suppressBusinessErrorToast: true}; const PAGE_PARAMS = {query: '', page_no: 1, page_size: 200}; const responseRecords = response => response?.data?.records ?? []; +const adminRole = { + role_id: PERMISSION_PRESETS.GS_ADMIN, + permission_preset: PERMISSION_PRESETS.GS_ADMIN, +}; + +const mergeMembersAndAdmins = (members, admins) => { + const rows = new Map(members.map(member => [ + member.user_id, + {...member, member_roles: member.roles ?? []}, + ])); + admins.forEach(admin => { + const userId = admin.id ?? admin.user_id; + const existing = rows.get(userId) ?? {}; + rows.set(userId, { + ...existing, + user_id: userId, + user_name: admin.name ?? admin.user_name ?? existing.user_name, + member_roles: existing.roles ?? existing.member_roles ?? [], + roles: [...(existing.roles ?? existing.member_roles ?? []), adminRole], + is_space_admin: true, + }); + }); + return Array.from(rows.values()); +}; + const rolePreset = role => { const explicit = role?.permission_preset ?? role?.permissionPreset; if (Object.values(PERMISSION_PRESETS).includes(explicit)) { @@ -68,11 +93,17 @@ const rolePreset = role => { return null; }; +const rolesPreset = roles => { + const values = roles ?? []; + const presets = values.map(rolePreset); + return values.length > 0 && presets.every(Boolean) && new Set(presets).size === 1 ? presets[0] : 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'); + : t('account.permission_preset.legacy_custom'); }; const RowAction = ({row, onAction, children}) => { @@ -212,20 +243,28 @@ const SpaceAccess = () => { const loadMembers = useCallback(space => api.auth.getSpaceMembers( space, PAGE_PARAMS, PAGE_ERROR_CONFIG ), []); - const loadRoles = useCallback(space => api.auth.getSpaceRoles( + const loadAdmins = useCallback(space => api.auth.getSpaceAdmins( space, PAGE_PARAMS, PAGE_ERROR_CONFIG ), []); const members = useScopedResource( graphSpace, contextVersion, loadMembers, responseRecords ); - const roles = useScopedResource( - graphSpace, contextVersion, loadRoles, responseRecords + const admins = useScopedResource(graphSpace, contextVersion, loadAdmins, responseRecords ); + const visibleMembers = { + data: mergeMembersAndAdmins(members.data, admins.data), + loading: members.loading || admins.loading, + error: members.error || admins.error, + retry: () => { + members.retry(); + admins.retry(); + }, + }; const refreshAll = useCallback(() => { members.retry(); - roles.retry(); - }, [members, roles]); + admins.retry(); + }, [admins, members]); const runMutation = useCallback(async (operation, close) => { if (submitting) { @@ -253,7 +292,7 @@ const SpaceAccess = () => { const openMember = useCallback(row => { memberForm.setFieldsValue({ user_id: row?.user_id, - permission_preset: rolePreset(row?.roles?.[0]), + permission_preset: rolesPreset(row?.roles), }); setMemberDialog(row ?? {}); }, [memberForm]); @@ -262,27 +301,13 @@ const SpaceAccess = () => { memberForm.resetFields(); }, [memberForm]); const submitMember = useCallback(values => { - 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: role ? [{ - role_id: role.id, - role_name: role.role_name ?? role.role_nickname ?? values.permission_preset, - }] : [], - }; - const operation = memberDialog?.user_id - ? () => api.auth.updateSpaceMember( - graphSpace, memberDialog.user_id, payload, PAGE_ERROR_CONFIG - ) - : () => api.auth.addSpaceMember( - graphSpace, payload, PAGE_ERROR_CONFIG - ); - runMutation(operation, closeMember); - }, [closeMember, graphSpace, memberDialog, roles.data, runMutation, t]); + runMutation( + () => api.auth.setSpacePreset(graphSpace, values.user_id, values.permission_preset, + PAGE_ERROR_CONFIG + ), + closeMember + ); + }, [closeMember, graphSpace, runMutation]); const confirmDelete = useCallback((title, operation) => { Modal.confirm({ @@ -392,7 +417,7 @@ const SpaceAccess = () => { key: 'members', label: t('account.space_access.tabs.members'), children: table( - members, memberColumns, 'user_id', + visibleMembers, memberColumns, 'user_id', t('account.space_access.member.add'), addMember, canAddMember ), @@ -437,4 +462,5 @@ const SpaceAccess = () => { ); }; +export {rolesPreset}; export default SpaceAccess; 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 a74866cc8..7367678f9 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js @@ -17,7 +17,7 @@ */ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; -import SpaceAccess from './SpaceAccess'; +import SpaceAccess, {rolesPreset} from './SpaceAccess'; import * as api from '../../api'; let mockAuthContext; @@ -33,6 +33,10 @@ jest.mock('../../auth/AuthContext', () => ({ jest.mock('../../api', () => ({ auth: { getSpaceMembers: jest.fn(), + getSpaceAdmins: jest.fn(), + setSpaceAdmin: jest.fn(), + removeSpaceAdmin: jest.fn(), + setSpacePreset: jest.fn(), getSpaceRoles: jest.fn(), getSpaceTargets: jest.fn(), getSpaceAccesses: jest.fn(), @@ -66,11 +70,23 @@ const page = records => ({status: 200, data: {records, total: records.length}}); const setResponses = ({members = [], roles = [], targets = [], accesses = []} = {}) => { api.auth.getSpaceMembers.mockResolvedValue(page(members)); + api.auth.getSpaceAdmins.mockResolvedValue(page([])); api.auth.getSpaceRoles.mockResolvedValue(page(roles)); api.auth.getSpaceTargets.mockResolvedValue(page(targets)); api.auth.getSpaceAccesses.mockResolvedValue({status: 200, data: accesses}); }; +test('requires explicit preset for mixed or legacy member roles', () => { + expect(rolesPreset([ + {role_name: 'observer'}, + {role_name: 'custom-role'}, + ])).toBeNull(); + expect(rolesPreset([ + {role_name: 'observer'}, + {role_name: 'analyst'}, + ])).toBeNull(); +}); + beforeEach(() => { jest.clearAllMocks(); mockAuthContext = { @@ -106,7 +122,8 @@ test('uses only path-scoped APIs for a space administrator', async () => { expect(api.auth.getSpaceMembers).toHaveBeenCalledWith( 'SPACE_A', expect.any(Object), expect.any(Object) ); - expect(api.auth.getSpaceRoles).toHaveBeenCalledWith( + expect(api.auth.getSpaceRoles).not.toHaveBeenCalled(); + expect(api.auth.getSpaceAdmins).toHaveBeenCalledWith( 'SPACE_A', expect.any(Object), expect.any(Object) ); expect(api.auth.getSpaceTargets).not.toHaveBeenCalled(); @@ -169,12 +186,8 @@ test('does not infer mutations when the server grants read-only actions', async })).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}); +test('submits only the selected preset when adding a member', async () => { + api.auth.setSpacePreset.mockResolvedValue({status: 200}); render(<SpaceAccess />); await screen.findAllByText('alice'); @@ -190,18 +203,13 @@ test('maps a selected preset to the authoritative role id when adding a member', 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) + await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledWith( + 'SPACE_A', 'bob', '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}); +test('uses the preset API for GS admin', async () => { + api.auth.setSpacePreset.mockResolvedValue({status: 200}); render(<SpaceAccess />); await screen.findAllByText('alice'); @@ -214,8 +222,24 @@ test('does not submit when the selected preset has no server role', async () => }); const comboboxes = screen.getAllByRole('combobox'); fireEvent.mouseDown(comboboxes[comboboxes.length - 1]); - fireEvent.click(screen.getByText('account.permission_preset.GS_READ_WRITE')); + fireEvent.click(screen.getByText('account.permission_preset.GS_ADMIN')); fireEvent.click(screen.getByRole('button', {name: 'OK'})); - await waitFor(() => expect(api.auth.addSpaceMember).not.toHaveBeenCalled()); + await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledWith( + 'SPACE_A', 'bob', 'GS_ADMIN', expect.any(Object) + )); + expect(api.auth.addSpaceMember).not.toHaveBeenCalled(); +}); + +test('shows GraphSpace administrators through the preset model', async () => { + api.auth.getSpaceAdmins.mockResolvedValueOnce(page([{ + id: 'admin-id', + name: 'space-admin', + }])); + + render(<SpaceAccess />); + + expect(await screen.findByText('space-admin')).toBeInTheDocument(); + expect(screen.getByText('account.permission_preset.GS_ADMIN')) + .toBeInTheDocument(); }); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/index.js b/hugegraph-hubble/hubble-fe/src/pages/Account/index.js index 7c0bd4aae..572795bc4 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/index.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/index.js @@ -137,6 +137,9 @@ const GlobalAccounts = () => { width: 140, render: row => { const preset = getAccountPreset(row); + if (!preset) { + return <Tag>{t('account.permission_preset.mixed')}</Tag>; + } 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 index 99fb9a64e..072d8a227 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.js @@ -34,9 +34,21 @@ const getAccountPreset = account => { if (account?.is_superadmin) { return PERMISSION_PRESETS.SUPER_ADMIN; } - const scopedPresets = (account?.graphspace_permissions ?? []) + if (explicit === 'LEGACY_CUSTOM') { + return null; + } + const scopedPermissions = Array.isArray(account?.graphspace_permissions) ? account.graphspace_permissions : []; + const scopedPresets = [...scopedPermissions, + ...(Array.isArray(account?.adminSpaces) + ? account.adminSpaces.map(graphspace => ({ + graphspace, + permission_preset: PERMISSION_PRESETS.GS_ADMIN, + })) : [])] .map(permission => permission?.permission_preset) .filter(preset => presetKeys.includes(preset)); + if (new Set(scopedPresets).size > 1) { + return null; + } if (scopedPresets.includes(PERMISSION_PRESETS.GS_ADMIN)) { return PERMISSION_PRESETS.GS_ADMIN; } @@ -50,10 +62,22 @@ const getAccountPreset = account => { }; const getPresetSpaces = account => { - const spaces = account?.graphspace_permissions ?? account?.adminSpaces; + const scoped = Array.isArray(account?.graphspace_permissions) + ? account.graphspace_permissions : []; + const admins = Array.isArray(account?.adminSpaces) + ? account.adminSpaces.map(graphspace => ({ + graphspace, + permission_preset: PERMISSION_PRESETS.GS_ADMIN, + })) : []; + const spaces = [...scoped, ...admins]; return Array.isArray(spaces) - ? spaces.map(space => (typeof space === 'string' - ? space : space.name ?? space.graphspace)).filter(Boolean) + ? Array.from(new Set(spaces.map(space => { + if (typeof space === 'string') { + return space; + } + const value = space?.name ?? space?.graphspace; + return typeof value === 'string' ? value : value?.name; + }).filter(Boolean))) : []; }; diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.test.js index cf287cae4..bed366348 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/permissionPresets.test.js @@ -68,3 +68,22 @@ test('clears admin spaces for the super administrator preset', () => { is_superadmin: true, }); }); + +test('requires an explicit choice before replacing mixed GraphSpace presets', () => { + expect(getAccountPreset({ + graphspace_permissions: [ + {graphspace: 'read', permission_preset: 'GS_READ_ONLY'}, + {graphspace: 'write', permission_preset: 'GS_READ_WRITE'}, + ], + })).toBeNull(); +}); + +test('requires an explicit choice before migrating legacy custom access', () => { + expect(getAccountPreset({ + permission_preset: 'LEGACY_CUSTOM', + graphspace_permissions: [{ + graphspace: 'legacy', + permission_preset: 'GS_READ_WRITE', + }], + })).toBeNull(); +}); diff --git a/hugegraph-hubble/hubble-fe/src/routes/index.js b/hugegraph-hubble/hubble-fe/src/routes/index.js index 8d7a27599..307217715 100644 --- a/hugegraph-hubble/hubble-fe/src/routes/index.js +++ b/hugegraph-hubble/hubble-fe/src/routes/index.js @@ -89,7 +89,7 @@ const AccountRoute = () => { return hasCapability('accounts_manage') || hasCapability('graphspace_members_manage') ? <Account /> - : <Navigate to='/profile' replace />; + : <Navigate to='/navigation' replace />; }; const GraphSpaceListRoute = () => { @@ -102,7 +102,7 @@ const GraphSpaceListRoute = () => { return null; } return hasCapability('graphspaces_read') - ? <GraphSpace /> : <Navigate to='/profile' replace />; + ? <GraphSpace /> : <Navigate to='/navigation' replace />; }; const LegacyProfileRedirect = () => { @@ -140,7 +140,12 @@ const GraphspaceParamRoute = ({children, fallback}) => { const RouteList = ({element}) => { return ( <Routes> - <Route path="/login" element={<Login />} /> + <Route + path="/login" + element={isAuthEnabled() + ? <Login /> + : <Navigate to='/navigation' replace />} + /> <Route path="/" element={( @@ -184,7 +189,12 @@ const RouteList = ({element}) => { <Route path="/task/edit" element={<TaskEdit />} /> <Route path="/task/detail/:taskid" element={<TaskDetail />} /> - <Route path='/profile' element={<My />} /> + <Route + path='/profile' + element={isAuthEnabled() + ? <My /> + : <Navigate to='/navigation' replace />} + /> <Route path='/my' element={<LegacyProfileRedirect />} /> <Route path='/resource' element={<Navigate to='/navigation' replace />} /> <Route path='/role' element={<Navigate to='/navigation' replace />} /> diff --git a/hugegraph-hubble/hubble-fe/src/routes/route-guard.test.js b/hugegraph-hubble/hubble-fe/src/routes/route-guard.test.js index 930013503..070bf67e4 100644 --- a/hugegraph-hubble/hubble-fe/src/routes/route-guard.test.js +++ b/hugegraph-hubble/hubble-fe/src/routes/route-guard.test.js @@ -19,7 +19,7 @@ import {render, screen} from '@testing-library/react'; import {MemoryRouter, Outlet, useLocation} from 'react-router-dom'; import RouteList from './index'; -import {isPdEnabled} from '../utils/config'; +import {isAuthEnabled, isPdEnabled} from '../utils/config'; let mockCapabilities = new Set(); @@ -54,6 +54,7 @@ jest.mock('../pages/Test', () => () => <div>test page</div>); jest.mock('../pages/GraphAnalysis', () => () => <div>graph analysis page</div>); jest.mock('../pages/AsyncTaskResult', () => () => <div>async task result page</div>); jest.mock('../utils/config', () => ({ + isAuthEnabled: jest.fn(() => true), isPdEnabled: jest.fn(() => false), })); jest.mock('../auth/AuthContext', () => ({ @@ -97,6 +98,7 @@ describe('route guard', () => { sessionStorage.clear(); mockAccountRender.mockClear(); mockCapabilities = new Set(); + isAuthEnabled.mockReturnValue(true); isPdEnabled.mockReturnValue(false); }); @@ -125,6 +127,17 @@ describe('route guard', () => { expect(sessionStorage.getItem('redirect')).toBeNull(); }); + it.each(['/login', '/profile'])('hides authentication route %s in anonymous mode', + route => { + isAuthEnabled.mockReturnValue(false); + renderRoutes(route); + + expect(screen.getByText('navigation page')).toBeTruthy(); + expect(screen.queryByText(/^login:/)).toBeNull(); + expect(screen.queryByText('my page')).toBeNull(); + } + ); + it('renders the standard 404 surface for an unknown operations route', () => { sessionStorage.setItem('user_', JSON.stringify({ id: 'admin', @@ -188,7 +201,7 @@ describe('route guard', () => { renderRoutes('/account'); - expect(screen.getByText('my page')).toBeTruthy(); + expect(screen.getByText('navigation page')).toBeTruthy(); expect(screen.queryByText('account page')).toBeNull(); expect(mockAccountRender).not.toHaveBeenCalled(); }); @@ -231,7 +244,7 @@ describe('route guard', () => { it.each([ ['/graphspace', 'graph page'], - ['/account', 'my page'], + ['/account', 'navigation page'], ])('uses the non-PD fallback for %s', (route, page) => { sessionStorage.setItem('user_', JSON.stringify({ id: 'admin', @@ -280,7 +293,7 @@ describe('route guard', () => { renderRoutes('/account'); - expect(screen.getByText('my page')).toBeTruthy(); + expect(screen.getByText('navigation page')).toBeTruthy(); expect(screen.queryByText('account page')).toBeNull(); }); diff --git a/tools/checkstyle.xml b/tools/checkstyle.xml index 60b785836..99f32095e 100644 --- a/tools/checkstyle.xml +++ b/tools/checkstyle.xml @@ -25,7 +25,7 @@ <property name="eachLine" value="true"/> </module> <module name="LineLength"> - <property name="max" value="100"/> + <property name="max" value="120"/> <property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://|Reflection\.*"/> </module> <module name="RegexpSingleline">
