You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by ji...@apache.org on 2026/08/30 10:58:21 UTC
(hugegraph-toolchain) 06/24: fix(hubble): harden account template boundaries
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 4ef08b628015614cd7705ca7023ae64a7ab1e2aa
Author: dark <ji...@apache.org>
AuthorDate: Tue Aug 18 18:59:43 2026 +0800
fix(hubble): harden account template boundaries
- enforce template ownership and pagination
- reset stale account and GraphSpace context
- separate account identities
- reject or roll back unsafe permission updates
---
.../org/apache/hugegraph/driver/AuthManager.java | 5 +
.../org/apache/hugegraph/driver/HugeClient.java | 33 ++
.../hugegraph/driver/ServerCompatibility.java | 21 +-
.../driver/HugeClientCompatibilityTest.java | 97 ++++++
.../hugegraph/driver/ServerCompatibilityTest.java | 6 +
.../org/apache/hugegraph/unit/ManagerAPITest.java | 15 +-
.../controller/auth/GraphSpaceUserController.java | 40 ++-
.../controller/space/GraphSpaceController.java | 30 +-
.../controller/space/SchemaTemplateController.java | 28 +-
.../apache/hugegraph/handler/LoginInterceptor.java | 26 +-
.../hugegraph/service/auth/AuthContextService.java | 13 +-
.../service/auth/GraphSpaceUserService.java | 134 +++++--
.../apache/hugegraph/service/auth/UserService.java | 216 ++++++++++--
.../hugegraph/service/space/GraphSpaceService.java | 205 ++++++++---
.../src/main/resources/i18n/messages.properties | 6 +
.../main/resources/i18n/messages_zh_CN.properties | 6 +
.../auth/GraphSpaceAuthOwnershipTest.java | 9 +-
.../controller/space/GraphSpaceControllerTest.java | 45 +++
.../SchemaTemplateControllerSecurityTest.java | 124 ++++++-
.../service/auth/AuthContextServiceTest.java | 6 +
.../service/auth/GraphSpaceUserServiceTest.java | 181 ++++++++++
.../service/space/GraphSpaceServiceTest.java | 162 ++++++++-
.../apache/hugegraph/unit/AuthSecurityTest.java | 16 +
.../unit/UserServiceCompatibilityTest.java | 383 +++++++++++++++++++--
.../hubble-fe/src/api/auth-contract.test.js | 8 +-
hugegraph-hubble/hubble-fe/src/api/auth.js | 4 +-
.../src/api/request-error-semantics.test.js | 47 +++
hugegraph-hubble/hubble-fe/src/api/request.js | 6 +
.../src/components/GraphContextSwitcher/index.js | 28 +-
.../components/GraphContextSwitcher/index.test.js | 30 +-
.../hubble-fe/src/components/Topbar/index.ant.js | 51 ++-
.../components/Topbar/topbar-request-error.test.js | 49 ++-
.../hubble-fe/src/pages/Account/EditLayer.js | 8 +-
.../hubble-fe/src/pages/Account/SpaceAccess.js | 53 ++-
.../src/pages/Account/SpaceAccess.test.js | 92 ++++-
.../pages/Account/account-edit-recovery.test.js | 138 +++++++-
.../hubble-fe/src/pages/Account/pagedRecords.js | 74 ++++
hugegraph-hubble/hubble-fe/src/pages/My/index.js | 37 +-
.../hubble-fe/src/pages/My/my-recovery.test.js | 30 ++
hugegraph-hubble/hubble-fe/src/utils/user.js | 14 +
40 files changed, 2193 insertions(+), 283 deletions(-)
diff --git a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/AuthManager.java b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/AuthManager.java
index af69f518b..f8cc35216 100644
--- a/hugegraph-client/src/main/java/org/apache/hugegraph/driver/AuthManager.java
+++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/AuthManager.java
@@ -385,6 +385,11 @@ public class AuthManager {
.checkPermission(HugePermission.SPACE, graphSpace);
}
+ public boolean isSpaceMember(String graphSpace) {
+ return this.managerAPI(graphSpace)
+ .checkPermission(HugePermission.SPACE_MEMBER, graphSpace);
+ }
+
public boolean checkDefaultRole(String graphSpace, String role) {
return this.managerAPI(graphSpace)
.checkDefaultRole(graphSpace, role, "");
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 41cf3e447..c314405f9 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
@@ -22,9 +22,12 @@ import java.io.Closeable;
import lombok.Getter;
import org.apache.hugegraph.client.RestClient;
+import org.apache.hugegraph.exception.ServerException;
import org.apache.hugegraph.rest.ClientException;
import org.apache.hugegraph.rest.RestClientConfig;
+import org.apache.hugegraph.structure.auth.TokenPayload;
import org.apache.hugegraph.structure.auth.User;
+import org.apache.hugegraph.util.E;
import org.apache.hugegraph.util.VersionUtil;
import org.apache.hugegraph.version.ClientVersion;
import org.slf4j.Logger;
@@ -265,6 +268,10 @@ public class HugeClient implements Closeable {
return this.compatibility.supportsDefaultRole();
}
+ public boolean supportsPersonalProfileUpdate() {
+ return this.compatibility.supportsPersonalProfileUpdate();
+ }
+
public User findUserByName(String name) {
if (this.supportsDefaultRole()) {
return this.auth.getUserByName(name);
@@ -275,6 +282,32 @@ public class HugeClient implements Closeable {
.orElse(null);
}
+ public User findCurrentUser(String expectedUsername) {
+ TokenPayload payload = this.auth.verifyToken();
+ E.checkState(payload != null &&
+ !Strings.isNullOrEmpty(payload.userId()) &&
+ !Strings.isNullOrEmpty(payload.username()),
+ "Invalid current-user identity");
+ E.checkState(payload.username().equals(expectedUsername),
+ "Authenticated user does not match the expected user");
+
+ User user;
+ try {
+ user = this.auth.getUser(payload.userId());
+ } catch (ServerException e) {
+ if (e.status() != 403 ||
+ this.supportsPersonalProfileUpdate()) {
+ throw e;
+ }
+ user = new User();
+ user.setId(payload.userId());
+ user.name(payload.username());
+ }
+ E.checkState(user != null && expectedUsername.equals(user.name()),
+ "Current-user record does not match the token identity");
+ return user;
+ }
+
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 b695edfe8..ac329aacf 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
@@ -74,17 +74,26 @@ public final class ServerCompatibility {
return profile(coreVersion, apiVersion).supportsDefaultRole();
}
+ public static boolean supportsPersonalProfileUpdate(
+ String coreVersion, String apiVersion) {
+ return profile(coreVersion, apiVersion)
+ .supportsPersonalProfileUpdate();
+ }
+
public enum Profile {
- LEGACY(false, false),
- GRAPHSPACE(true, false),
- MODERN(true, true);
+ LEGACY(false, false, false),
+ GRAPHSPACE(true, false, false),
+ MODERN(true, true, true);
private final boolean graphSpace;
private final boolean defaultRole;
+ private final boolean personalProfileUpdate;
- Profile(boolean graphSpace, boolean defaultRole) {
+ Profile(boolean graphSpace, boolean defaultRole,
+ boolean personalProfileUpdate) {
this.graphSpace = graphSpace;
this.defaultRole = defaultRole;
+ this.personalProfileUpdate = personalProfileUpdate;
}
public boolean supportsGraphSpace() {
@@ -94,5 +103,9 @@ public final class ServerCompatibility {
public boolean supportsDefaultRole() {
return this.defaultRole;
}
+
+ public boolean supportsPersonalProfileUpdate() {
+ return this.personalProfileUpdate;
+ }
}
}
diff --git a/hugegraph-client/src/test/java/org/apache/hugegraph/driver/HugeClientCompatibilityTest.java b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/HugeClientCompatibilityTest.java
index aed9bb494..6e14e4ecf 100644
--- a/hugegraph-client/src/test/java/org/apache/hugegraph/driver/HugeClientCompatibilityTest.java
+++ b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/HugeClientCompatibilityTest.java
@@ -19,6 +19,8 @@ package org.apache.hugegraph.driver;
import java.util.Arrays;
+import org.apache.hugegraph.exception.ServerException;
+import org.apache.hugegraph.structure.auth.TokenPayload;
import org.apache.hugegraph.structure.auth.User;
import org.apache.hugegraph.testutil.Whitebox;
import org.junit.Assert;
@@ -69,6 +71,101 @@ public class HugeClientCompatibilityTest {
.getUserByName(Mockito.anyString());
}
+ @Test
+ public void shouldFindCurrentUserFromVerifiedTokenIdentity() {
+ TokenPayload payload = Mockito.mock(TokenPayload.class);
+ User alice = user("alice");
+ Mockito.when(payload.userId()).thenReturn("user-id");
+ Mockito.when(payload.username()).thenReturn("alice");
+ Mockito.when(this.auth.verifyToken()).thenReturn(payload);
+ Mockito.when(this.auth.getUser("user-id")).thenReturn(alice);
+
+ Assert.assertSame(alice, this.client.findCurrentUser("alice"));
+ Mockito.verify(this.auth, Mockito.never()).listUsers();
+ Mockito.verify(this.auth, Mockito.never())
+ .getUserByName(Mockito.anyString());
+ }
+
+ @Test
+ public void shouldUseVerifiedIdentityWhenLegacySelfReadIsForbidden() {
+ TokenPayload payload = Mockito.mock(TokenPayload.class);
+ ServerException forbidden = new ServerException("forbidden");
+ forbidden.status(403);
+ Whitebox.setInternalState(
+ this.client, "compatibility",
+ ServerCompatibility.Profile.GRAPHSPACE);
+ Mockito.when(payload.userId()).thenReturn("user-id");
+ Mockito.when(payload.username()).thenReturn("alice");
+ Mockito.when(this.auth.verifyToken()).thenReturn(payload);
+ Mockito.when(this.auth.getUser("user-id")).thenThrow(forbidden);
+
+ User user = this.client.findCurrentUser("alice");
+
+ Assert.assertEquals("user-id", user.id());
+ Assert.assertEquals("alice", user.name());
+ Mockito.verify(this.auth, Mockito.never()).listUsers();
+ }
+
+ @Test
+ public void shouldNotHideForbiddenModernSelfRead() {
+ TokenPayload payload = Mockito.mock(TokenPayload.class);
+ ServerException forbidden = new ServerException("forbidden");
+ forbidden.status(403);
+ Whitebox.setInternalState(
+ this.client, "compatibility",
+ ServerCompatibility.Profile.MODERN);
+ Mockito.when(payload.userId()).thenReturn("user-id");
+ Mockito.when(payload.username()).thenReturn("alice");
+ Mockito.when(this.auth.verifyToken()).thenReturn(payload);
+ Mockito.when(this.auth.getUser("user-id")).thenThrow(forbidden);
+
+ try {
+ this.client.findCurrentUser("alice");
+ Assert.fail("Expected modern self-read failure");
+ } catch (ServerException ignored) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void shouldNotUseLegacyFallbackForInvalidOrMissingUser() {
+ TokenPayload payload = Mockito.mock(TokenPayload.class);
+ Whitebox.setInternalState(
+ this.client, "compatibility",
+ ServerCompatibility.Profile.GRAPHSPACE);
+ Mockito.when(payload.userId()).thenReturn("user-id");
+ Mockito.when(payload.username()).thenReturn("alice");
+ Mockito.when(this.auth.verifyToken()).thenReturn(payload);
+
+ for (int status : Arrays.asList(401, 404)) {
+ ServerException failure = new ServerException("failure");
+ failure.status(status);
+ Mockito.doThrow(failure).when(this.auth).getUser("user-id");
+ try {
+ this.client.findCurrentUser("alice");
+ Assert.fail("Expected legacy self-read failure");
+ } catch (ServerException actual) {
+ Assert.assertSame(failure, actual);
+ }
+ }
+ }
+
+ @Test
+ public void shouldRejectMismatchedCurrentUserIdentity() {
+ TokenPayload payload = Mockito.mock(TokenPayload.class);
+ Mockito.when(payload.userId()).thenReturn("user-id");
+ Mockito.when(payload.username()).thenReturn("bob");
+ Mockito.when(this.auth.verifyToken()).thenReturn(payload);
+
+ try {
+ this.client.findCurrentUser("alice");
+ Assert.fail("Expected a mismatched current-user identity");
+ } catch (IllegalStateException ignored) {
+ // Expected
+ }
+ Mockito.verify(this.auth, Mockito.never()).getUser(Mockito.any());
+ }
+
private static User user(String name) {
User user = new User();
user.name(name);
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 05ebb41dd..577071974 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
@@ -41,6 +41,12 @@ public class ServerCompatibilityTest {
"1.7.0", "0.72.0.0"));
Assert.assertFalse(ServerCompatibility.supportsDefaultRole(
"1.8.0", "0.71.0.0"));
+ Assert.assertFalse(
+ ServerCompatibility.supportsPersonalProfileUpdate(
+ "1.7.0", "0.71.0.0"));
+ Assert.assertTrue(
+ ServerCompatibility.supportsPersonalProfileUpdate(
+ "1.8.0", "0.72.0.0"));
Assert.assertEquals(ServerCompatibility.Profile.GRAPHSPACE,
ServerCompatibility.profile("1.7.1",
"0.71.0.0"));
diff --git a/hugegraph-client/src/test/java/org/apache/hugegraph/unit/ManagerAPITest.java b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/ManagerAPITest.java
index 7fdf1aebd..e98ebcb67 100644
--- a/hugegraph-client/src/test/java/org/apache/hugegraph/unit/ManagerAPITest.java
+++ b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/ManagerAPITest.java
@@ -146,21 +146,28 @@ public class ManagerAPITest extends BaseUnitTest {
AuthManager auth = new AuthManager(client, "DEFAULT", null);
Assert.assertTrue(auth.isSpaceAdmin("space_a"));
- Assert.assertTrue(auth.checkDefaultRole("space_b", "analyst"));
+ Assert.assertTrue(auth.isSpaceMember("space_b"));
+ Assert.assertTrue(auth.checkDefaultRole("space_c", "analyst"));
Assert.assertEquals("graphspaces/space_a/auth/managers/check",
path.getAllValues().get(0));
- Assert.assertEquals("graphspaces/space_b/auth/managers/default",
+ Assert.assertEquals("graphspaces/space_b/auth/managers/check",
path.getAllValues().get(1));
+ Assert.assertEquals("graphspaces/space_c/auth/managers/default",
+ path.getAllValues().get(2));
Assert.assertEquals(HugePermission.SPACE,
params.getAllValues().get(0).get("type"));
+ Assert.assertEquals(HugePermission.SPACE_MEMBER,
+ params.getAllValues().get(1).get("type"));
Assert.assertEquals("space_a",
params.getAllValues().get(0).get("graphspace"));
Assert.assertEquals("space_b",
params.getAllValues().get(1).get("graphspace"));
+ Assert.assertEquals("space_c",
+ params.getAllValues().get(2).get("graphspace"));
Assert.assertEquals("analyst",
- params.getAllValues().get(1).get("role"));
- Assert.assertFalse(params.getAllValues().get(1).containsKey("graph"));
+ params.getAllValues().get(2).get("role"));
+ Assert.assertFalse(params.getAllValues().get(2).containsKey("graph"));
}
@Test
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 03dd4fb70..5302227f0 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
@@ -27,6 +27,7 @@ import org.apache.hugegraph.entity.auth.UserView;
import org.apache.hugegraph.service.auth.GraphSpaceUserService;
import org.apache.hugegraph.structure.auth.User;
import org.apache.hugegraph.structure.auth.UserManager;
+import org.apache.hugegraph.util.E;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -86,18 +87,25 @@ public class GraphSpaceUserController extends AuthController {
@PathVariable("id") String userId) {
HugeClient client =
this.requireGraphSpaceAuthorizationAdmin(graphSpace);
- return client.auth().addSpaceAdmin(userId, graphSpace);
+ User account = client.auth().getUser(userId);
+ E.checkNotNull(account, "User");
+ return client.auth().addSpaceAdmin(account.name(), graphSpace);
}
@PutMapping("{id}/preset")
public void setPermissionPreset(
@PathVariable("graphspace") String graphSpace,
- @PathVariable("id") String userId,
+ @PathVariable("id") String identity,
@RequestBody Map<String, String> body) {
String preset = body.get("permission_preset");
- HugeClient client = this.requirePresetManager(graphSpace, userId,
+ String userId = body.get("user_id");
+ String username = body.get("username");
+ HugeClient client = this.requirePresetManager(graphSpace, username,
preset);
- this.userService.applySpacePreset(client, graphSpace, userId, preset);
+ E.checkArgument(identity.equals(userId) ||
+ identity.equals(username),
+ "The account identity in the path and body must match");
+ this.userService.applySpacePreset(client, graphSpace, userId, username, preset);
}
@DeleteMapping("spaceadmin/{id}")
@@ -106,7 +114,9 @@ public class GraphSpaceUserController extends AuthController {
@PathVariable("id") String userId) {
HugeClient client =
this.requireGraphSpaceAuthorizationAdmin(graphSpace);
- client.auth().delSpaceAdmin(userId, graphSpace);
+ User account = client.auth().getUser(userId);
+ E.checkNotNull(account, "User");
+ client.auth().delSpaceAdmin(account.name(), graphSpace);
}
@PostMapping
@@ -134,19 +144,31 @@ public class GraphSpaceUserController extends AuthController {
this.userService.unauthUser(client, graphSpace, userId);
}
- private HugeClient requirePresetManager(String graphSpace, String userId,
+ private HugeClient requirePresetManager(String graphSpace, String username,
String preset) {
if ("GS_ADMIN".equals(preset)) {
return this.requireGraphSpaceAuthorizationAdmin(graphSpace);
}
- return this.requireMemberManager(graphSpace, userId);
+ return this.requireMemberManagerByUsername(graphSpace, username);
}
private HugeClient requireMemberManager(String graphSpace,
String userId) {
HugeClient client = this.requireGraphSpaceManager(graphSpace);
- if (client.auth().listSuperAdmin().contains(userId) ||
- client.auth().listSpaceAdmin(graphSpace).contains(userId)) {
+ User account = client.auth().getUser(userId);
+ E.checkNotNull(account, "User");
+ return this.requireMemberManagerByUsername(client, graphSpace, account.name());
+ }
+
+ private HugeClient requireMemberManagerByUsername(String graphSpace, String username) {
+ HugeClient client = this.requireGraphSpaceManager(graphSpace);
+ return this.requireMemberManagerByUsername(client, graphSpace, username);
+ }
+
+ private HugeClient requireMemberManagerByUsername(HugeClient client, String graphSpace, String username) {
+ E.checkArgument(username != null && !username.isEmpty(), "The account name can't be empty");
+ if (client.auth().listSuperAdmin().contains(username) ||
+ client.auth().listSpaceAdmin(graphSpace).contains(username)) {
return this.requireGraphSpaceAuthorizationAdmin(graphSpace);
}
return client;
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 fb1d8900a..e3a8e2d6a 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
@@ -34,7 +34,6 @@ import org.apache.hugegraph.service.auth.UserService;
import org.apache.hugegraph.service.graphs.GraphsService;
import org.apache.hugegraph.service.space.GraphSpaceService;
import org.apache.hugegraph.util.E;
-import org.apache.hugegraph.util.PageUtil;
import org.apache.hugegraph.util.UrlUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -49,7 +48,6 @@ 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")
@@ -82,8 +80,11 @@ public class GraphSpaceController extends BaseController {
Collections.singletonList("DEFAULT"));
}
+ HugeClient client = this.authClient(null, null);
List<String> graphSpaces =
- this.graphSpaceService.listAll(this.authClient(null, null));
+ this.authMode != null && this.authMode.anonymous() ?
+ this.graphSpaceService.listAnonymous(client) :
+ this.graphSpaceService.listAll(client);
return ImmutableMap.of("graphspaces", graphSpaces);
}
@@ -104,8 +105,11 @@ public class GraphSpaceController extends BaseController {
}
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);
+ return all ?
+ this.graphSpaceService.queryAnonymousGs(client, query,
+ createTime) :
+ this.graphSpaceService.queryAnonymousGsPage(
+ client, query, createTime, pageNo, pageSize);
}
if (all) {
HugeClient client = this.authClient(null, null);
@@ -119,8 +123,8 @@ public class GraphSpaceController extends BaseController {
return this.userService.isSuperAdmin(client) ?
graphSpaceService.queryPage(client, query, createTime,
pageNo, pageSize) :
- PageUtil.page(graphSpaceService.queryAccessibleGs(
- client, query, createTime), pageNo, pageSize);
+ graphSpaceService.queryAccessibleGsPage(
+ client, query, createTime, pageNo, pageSize);
}
@GetMapping("{graphspace}/auth")
@@ -128,8 +132,11 @@ public class GraphSpaceController extends BaseController {
if (!isPdEnabled()) {
return ImmutableMap.of("auth", false);
}
- boolean isAuth = graphSpaceService.isAuth(this.authClient(null, null),
- graphSpace);
+ HugeClient client = this.authClient(null, null);
+ boolean isAuth =
+ this.authMode != null && this.authMode.anonymous() ?
+ this.graphSpaceService.isAuthForAnonymous(client, graphSpace) :
+ this.graphSpaceService.isAuth(client, graphSpace);
return ImmutableMap.of("auth", isAuth);
}
@@ -144,10 +151,7 @@ public class GraphSpaceController extends BaseController {
}
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);
+ return this.graphSpaceService.getAnonymous(client, graphspace);
}
// Get GraphSpace Info
return graphSpaceService.toView(
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/SchemaTemplateController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/SchemaTemplateController.java
index 7526183b5..f24182121 100644
--- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/SchemaTemplateController.java
+++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/SchemaTemplateController.java
@@ -19,6 +19,8 @@
package org.apache.hugegraph.controller.space;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import com.google.common.collect.ImmutableMap;
import org.springframework.beans.factory.annotation.Autowired;
@@ -36,6 +38,7 @@ import org.apache.hugegraph.common.Constant;
import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.controller.BaseController;
import org.apache.hugegraph.driver.HugeClient;
+import org.apache.hugegraph.exception.ForbiddenException;
import org.apache.hugegraph.options.HubbleOptions;
import org.apache.hugegraph.service.space.SchemaTemplateService;
import org.apache.hugegraph.structure.space.SchemaTemplate;
@@ -102,7 +105,8 @@ public class SchemaTemplateController extends BaseController {
@DeleteMapping("{name}")
public void delete(@PathVariable("graphspace") String graphSpace,
@PathVariable("name") String name) {
- HugeClient client = this.requireGraphSpaceWrite(graphSpace);
+ HugeClient client = this.requireTemplateOwnerOrManager(graphSpace,
+ name);
schemaTemplateService.delete(client, name);
}
@@ -110,8 +114,28 @@ public class SchemaTemplateController extends BaseController {
public Object update(@PathVariable("graphspace") String graphSpace,
@PathVariable("name") String name,
@RequestBody SchemaTemplate schemaTemplate) {
- HugeClient client = this.requireGraphSpaceWrite(graphSpace);
+ HugeClient client = this.requireTemplateOwnerOrManager(graphSpace,
+ name);
schemaTemplate.name(name);
return schemaTemplateService.update(client, schemaTemplate);
}
+
+ private HugeClient requireTemplateOwnerOrManager(String graphSpace,
+ String name) {
+ HugeClient client = this.requireGraphSpaceWrite(graphSpace);
+ if (this.authMode != null && this.authMode.anonymous()) {
+ return client;
+ }
+ if (this.userService.isSuperAdmin(client) ||
+ this.userService.isAssignSpaceAdmin(client, graphSpace)) {
+ return client;
+ }
+
+ Map template = this.schemaTemplateService.get(client, name);
+ if (!Objects.equals(this.getUser(), template.get("creator"))) {
+ throw new ForbiddenException(
+ "Permission denied: modify schema template");
+ }
+ return client;
+ }
}
diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java
index 5d49aa9f8..ae3baee1e 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
@@ -65,12 +65,32 @@ public class LoginInterceptor extends HandlerInterceptorAdapter {
}
private static boolean isAnonymousAuthManagement(String uri) {
- if (uri.contains("/graphspaces/") && uri.contains("/auth/")) {
+ int apiIndex = uri.indexOf(Constant.API_VERSION);
+ if (apiIndex < 0) {
+ return false;
+ }
+ String apiPath = uri.substring(apiIndex);
+ String globalAuth = Constant.API_VERSION + "auth";
+ if (apiPath.equals(globalAuth + "/context") ||
+ apiPath.equals(globalAuth + "/status") ||
+ apiPath.equals(globalAuth + "/logout")) {
+ return false;
+ }
+ if (apiPath.equals(globalAuth) ||
+ apiPath.startsWith(globalAuth + "/")) {
return true;
}
- if (!uri.contains("/auth/")) {
+
+ String graphSpaces = Constant.API_VERSION + "graphspaces/";
+ if (!apiPath.startsWith(graphSpaces)) {
+ return false;
+ }
+ int graphSpaceEnd = apiPath.indexOf('/', graphSpaces.length());
+ if (graphSpaceEnd < 0) {
return false;
}
- return !uri.endsWith("/auth/context") && !uri.endsWith("/auth/status") && !uri.endsWith("/auth/logout");
+ String scopedAuth = apiPath.substring(0, graphSpaceEnd) + "/auth";
+ return apiPath.equals(scopedAuth) ||
+ apiPath.startsWith(scopedAuth + "/");
}
}
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 c0e699172..80ad33a69 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
@@ -69,6 +69,8 @@ public class AuthContextService {
private static final char[] HEX = "0123456789abcdef".toCharArray();
private static final Set<String> SELF_ACTIONS = set(
"read", "update", "change_password");
+ private static final Set<String> LEGACY_SELF_ACTIONS = set(
+ "read", "change_password");
private static final Set<String> CRUD_ACTIONS = set(
"read", "create", "update", "delete");
private static final Set<String> MEMBER_ACTIONS = set(
@@ -116,6 +118,8 @@ public class AuthContextService {
boolean permissionPresets = pdEnabled &&
client.supportsDefaultRole();
+ boolean profileUpdate =
+ client.supportsPersonalProfileUpdate();
List<String> writeGraphSpaces = pdEnabled ?
writeGraphSpaces(user,
permissionPresets) :
@@ -123,7 +127,8 @@ public class AuthContextService {
Set<String> capabilities = this.capabilities(pdEnabled, role,
permissionPresets);
Map<String, Set<String>> actions = this.actions(pdEnabled, role,
- permissionPresets);
+ permissionPresets,
+ profileUpdate);
Map<String, Object> scopes = this.scopes(pdEnabled, role,
adminGraphSpaces,
writeGraphSpaces);
@@ -224,12 +229,14 @@ public class AuthContextService {
}
private Map<String, Set<String>> actions(boolean pdEnabled, String role,
- boolean permissionPresets) {
+ boolean permissionPresets,
+ boolean profileUpdate) {
Map<String, Set<String>> actions = new LinkedHashMap<>();
boolean superAdmin = SUPERADMIN.equals(role);
boolean spaceManager = pdEnabled && permissionPresets &&
(superAdmin || SPACEADMIN.equals(role));
- actions.put("account", SELF_ACTIONS);
+ actions.put("account", profileUpdate ?
+ SELF_ACTIONS : LEGACY_SELF_ACTIONS);
actions.put("accounts", superAdmin ? CRUD_ACTIONS : emptySet());
actions.put("graphspaces", pdEnabled ?
(superAdmin ? CRUD_ACTIONS : set("read")) : emptySet());
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 7feeadedc..325b6e382 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
@@ -167,6 +167,21 @@ public class GraphSpaceUserService extends AuthService {
public void applyPermissionPresets(HugeClient client, String username,
List<Map<String, String>> permissions,
String preset) {
+ this.applyPermissionPresets(client, username, permissions, preset,
+ true);
+ }
+
+ public void applyPermissionPresetsForNewAccount(
+ HugeClient client, String username,
+ List<Map<String, String>> permissions, String preset) {
+ this.applyPermissionPresets(client, username, permissions, preset,
+ false);
+ }
+
+ private void applyPermissionPresets(HugeClient client, String username,
+ List<Map<String, String>> permissions,
+ String preset,
+ boolean reconcileUnrequestedSpaces) {
if (preset == null || "SUPER_ADMIN".equals(preset)) {
return;
}
@@ -184,16 +199,44 @@ public class GraphSpaceUserService extends AuthService {
desired.put(graphSpace, permissionPreset);
}
}
- for (String graphSpace : client.graphSpace().listGraphSpace()) {
- String desiredPreset = desired.get(graphSpace);
- if (desiredPreset == null) {
- this.unauthUser(client, graphSpace,
- account.id().toString());
- } else {
- this.applySpacePreset(client, graphSpace,
- account.id().toString(),
- desiredPreset);
+ if (!reconcileUnrequestedSpaces) {
+ desired.forEach((graphSpace, desiredPreset) ->
+ this.applySpacePreset(client, graphSpace,
+ account.id().toString(),
+ account.name(),
+ desiredPreset));
+ return;
+ }
+ List<String> graphSpaces = client.graphSpace().listGraphSpace();
+ Map<String, SpacePresetState> previous =
+ new java.util.LinkedHashMap<>();
+ String userId = account.id().toString();
+ String accountName = account.name();
+ for (String graphSpace : graphSpaces) {
+ boolean member = client.auth().listSpaceMember(graphSpace)
+ .contains(accountName);
+ previous.put(graphSpace, this.capturePresetState(
+ client, graphSpace, userId, accountName, member));
+ }
+ try {
+ for (String graphSpace : graphSpaces) {
+ String desiredPreset = desired.get(graphSpace);
+ if (desiredPreset == null) {
+ this.unauthUser(client, graphSpace, userId);
+ } else {
+ this.applySpacePreset(client, graphSpace, userId,
+ accountName,
+ desiredPreset);
+ }
+ }
+ } catch (RuntimeException error) {
+ for (int i = graphSpaces.size() - 1; i >= 0; i--) {
+ String graphSpace = graphSpaces.get(i);
+ this.restorePresetState(client, graphSpace, userId,
+ accountName, previous.get(graphSpace),
+ error);
}
+ throw error;
}
}
@@ -204,10 +247,19 @@ public class GraphSpaceUserService extends AuthService {
return;
}
requirePermissionPresets(client);
+ requirePermissionPreset(preset);
+ if (permissions == null || permissions.isEmpty()) {
+ throw new ParameterizedException(
+ "auth.permission-preset.graphspace-required");
+ }
Set<String> graphSpaces =
new java.util.HashSet<>(client.graphSpace().listGraphSpace());
for (Map<String, String> permission :
permissions == null ? new ArrayList<Map<String, String>>() : permissions) {
+ if (permission == null) {
+ throw new ParameterizedException(
+ "auth.permission-preset.entry-invalid");
+ }
String graphSpace = permission.get("graphspace");
String permissionPreset = permission.get("permission_preset");
if (graphSpace == null || !graphSpaces.contains(graphSpace)) {
@@ -216,29 +268,38 @@ public class GraphSpaceUserService extends AuthService {
graphSpace);
}
requirePermissionPreset(permissionPreset);
+ if (!preset.equals(permissionPreset)) {
+ throw new ParameterizedException(
+ "auth.permission-preset.mismatch",
+ permissionPreset, preset);
+ }
}
}
- public void applySpacePreset(HugeClient client, String graphSpace,
- String userId, String preset) {
+ public void applySpacePreset(HugeClient client, String graphSpace, String userId, String username, String preset) {
requirePermissionPreset(preset);
requirePermissionPresets(client);
- E.checkArgument(!client.auth().listSuperAdmin().contains(userId),
+ E.checkArgument(username != null && !username.isEmpty(), "The account name can't be empty");
+ E.checkArgument(!client.auth().listSuperAdmin().contains(username),
"Can't assign GraphSpace preset to super " +
- "administrator '%s'", userId);
+ "administrator '%s'", username);
boolean wasMember =
- client.auth().listSpaceMember(graphSpace).contains(userId);
- String username = userId;
+ client.auth().listSpaceMember(graphSpace).contains(username);
+ String resolvedUserId = userId;
SpacePresetState previous = null;
try {
if (!wasMember) {
- client.auth().addSpaceMember(userId, graphSpace);
+ client.auth().addSpaceMember(username, graphSpace);
}
- User account = client.auth().getUser(userId);
+ User account = userId == null ?
+ client.findUserByName(username) :
+ client.auth().getUser(userId);
E.checkNotNull(account, "User");
- username = account.name();
- previous = this.capturePresetState(client, graphSpace, userId,
- username, wasMember);
+ E.checkArgument(username.equals(account.name()),
+ "Account id '%s' belongs to '%s', not '%s'",
+ userId, account.name(), username);
+ resolvedUserId = account.id().toString();
+ previous = this.capturePresetState(client, graphSpace, resolvedUserId, username, wasMember);
previous.customRoles.forEach(
belong -> this.belongService.deleteById(
client, graphSpace, belong.getId()));
@@ -265,11 +326,10 @@ public class GraphSpaceUserService extends AuthService {
this.setDefaultRole(client, graphSpace, username, role);
} catch (RuntimeException e) {
if (previous == null) {
- this.rollbackNewMember(client, graphSpace, userId,
+ this.rollbackNewMember(client, graphSpace, username,
wasMember, e);
} else {
- this.restorePresetState(client, graphSpace, userId, username,
- previous, e);
+ this.restorePresetState(client, graphSpace, resolvedUserId, username, previous, e);
}
throw e;
}
@@ -308,6 +368,9 @@ public class GraphSpaceUserService extends AuthService {
String userId, String username,
SpacePresetState previous,
RuntimeException failure) {
+ if (previous.member) {
+ this.rollbackNewMember(client, graphSpace, username, true, failure);
+ }
previous.customRoles.forEach(belong -> {
this.tryRestore(() -> {
Set<String> currentRoles = this.belongService.list(
@@ -336,8 +399,9 @@ public class GraphSpaceUserService extends AuthService {
client.auth().delSpaceAdmin(username, graphSpace);
}
}, graphSpace, userId, "administrator", failure);
- this.rollbackNewMember(client, graphSpace, userId,
- previous.member, failure);
+ if (!previous.member) {
+ this.rollbackNewMember(client, graphSpace, username, false, failure);
+ }
}
private void restoreDefaultRole(HugeClient client, String graphSpace,
@@ -353,17 +417,17 @@ public class GraphSpaceUserService extends AuthService {
}
private void rollbackNewMember(HugeClient client, String graphSpace,
- String userId, boolean expected,
+ String username, boolean expected,
RuntimeException failure) {
this.tryRestore(() -> {
boolean current = client.auth().listSpaceMember(graphSpace)
- .contains(userId);
+ .contains(username);
if (expected && !current) {
- client.auth().addSpaceMember(userId, graphSpace);
+ client.auth().addSpaceMember(username, graphSpace);
} else if (!expected && current) {
- client.auth().delSpaceMember(userId, graphSpace);
+ client.auth().delSpaceMember(username, graphSpace);
}
- }, graphSpace, userId, "membership", failure);
+ }, graphSpace, username, "membership", failure);
}
private void tryRestore(Runnable action, String graphSpace,
@@ -386,14 +450,18 @@ public class GraphSpaceUserService extends AuthService {
}
private static void requirePermissionPreset(String preset) {
- if (!"GS_READ_ONLY".equals(preset) &&
- !"GS_READ_WRITE".equals(preset) &&
- !"GS_ADMIN".equals(preset)) {
+ if (!isGraphSpacePreset(preset)) {
throw new ParameterizedException(
"auth.permission-preset.invalid", preset);
}
}
+ private static boolean isGraphSpacePreset(String preset) {
+ return "GS_READ_ONLY".equals(preset) ||
+ "GS_READ_WRITE".equals(preset) ||
+ "GS_ADMIN".equals(preset);
+ }
+
public boolean hasCustomRoles(HugeClient client, String graphSpace,
String userId) {
return !this.belongService.list(client, graphSpace, null, userId).isEmpty();
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 003647598..0d44836db 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
@@ -42,7 +42,9 @@ import org.apache.hugegraph.driver.AuthManager;
import org.apache.hugegraph.driver.HugeClient;
import org.apache.hugegraph.entity.auth.UserEntity;
import org.apache.hugegraph.exception.InternalException;
+import org.apache.hugegraph.exception.ParameterizedException;
import org.apache.hugegraph.exception.ServerException;
+import org.apache.hugegraph.exception.UnauthorizedException;
import org.apache.hugegraph.structure.auth.User;
import org.apache.hugegraph.util.E;
import org.apache.hugegraph.util.HubbleUtil;
@@ -183,7 +185,7 @@ public class UserService extends AuthService {
}
public UserEntity getpersonal(HugeClient hugeClient, String username) {
- User user = hugeClient.findUserByName(username);
+ User user = currentUser(hugeClient, username);
if (user == null) {
throw new InternalException("auth.user.get.%s Not Exits",
username);
@@ -217,10 +219,10 @@ public class UserService extends AuthService {
}
public void add(HugeClient client, UserEntity ue) {
- if (isPdEnabled() && client.supportsDefaultRole()) {
- this.graphSpaceUserService.validatePermissionPresets(client, ue.getGraphspacePermissions(),
- ue.getPermissionPreset());
- }
+ boolean permissionPresets = isPdEnabled() &&
+ client.supportsDefaultRole();
+ this.validatePermissionMutation(client, ue, true,
+ permissionPresets);
User user = new User();
user.name(ue.getName());
user.password(ue.getPassword());
@@ -233,19 +235,60 @@ public class UserService extends AuthService {
}
User newUser = client.auth().createUser(user);
- if (ue.getAdminSpaces() != null) {
- for (String graphspace : ue.getAdminSpaces()) {
- client.auth().addSpaceAdmin(ue.getName(), graphspace);
+ List<String> attemptedAdminSpaces = new ArrayList<>();
+ boolean superAdminAttempted = false;
+ try {
+ if (!permissionPresets && ue.getAdminSpaces() != null) {
+ for (String graphspace : ue.getAdminSpaces()) {
+ attemptedAdminSpaces.add(graphspace);
+ client.auth().addSpaceAdmin(ue.getName(), graphspace);
+ }
+ }
+ if (permissionPresets) {
+ this.graphSpaceUserService
+ .applyPermissionPresetsForNewAccount(
+ client, ue.getName(),
+ ue.getGraphspacePermissions(),
+ ue.getPermissionPreset());
}
+ if (newUser != null && ue.isSuperadmin()) {
+ superAdminAttempted = true;
+ client.auth().addSuperAdmin(ue.getName());
+ }
+ } catch (RuntimeException error) {
+ this.rollbackNewAccount(client, newUser, ue.getName(),
+ attemptedAdminSpaces,
+ superAdminAttempted, error);
+ throw error;
}
- if (isPdEnabled() && client.supportsDefaultRole()) {
- this.graphSpaceUserService.applyPermissionPresets(client, ue.getName(), ue.getGraphspacePermissions(),
- ue.getPermissionPreset());
+ }
+
+ private void rollbackNewAccount(HugeClient client, User user,
+ String username,
+ List<String> attemptedAdminSpaces,
+ boolean superAdminAttempted,
+ RuntimeException failure) {
+ if (superAdminAttempted) {
+ this.suppressRollback(
+ () -> client.auth().delSuperAdmin(username), failure);
}
+ for (int i = attemptedAdminSpaces.size() - 1; i >= 0; i--) {
+ String graphSpace = attemptedAdminSpaces.get(i);
+ this.suppressRollback(
+ () -> client.auth().delSpaceAdmin(username, graphSpace),
+ failure);
+ }
+ if (user != null) {
+ this.suppressRollback(
+ () -> client.auth().deleteUser(user.id()), failure);
+ }
+ }
- if (newUser != null && ue.isSuperadmin()) {
- // add superadmin
- client.auth().addSuperAdmin(newUser.id().toString());
+ private void suppressRollback(Runnable rollback, RuntimeException failure) {
+ try {
+ rollback.run();
+ } catch (RuntimeException rollbackError) {
+ failure.addSuppressed(rollbackError);
}
}
@@ -488,11 +531,13 @@ public class UserService extends AuthService {
private static boolean hasCurrentUserAccess(HugeClient client,
String graphSpace) {
+ if (!client.supportsDefaultRole()) {
+ return client.auth().isSpaceMember(graphSpace);
+ }
if (client.auth().checkDefaultRole(graphSpace, "analyst")) {
return true;
}
- return client.supportsDefaultRole() &&
- hasCurrentUserObserverRole(client, graphSpace);
+ return hasCurrentUserObserverRole(client, graphSpace);
}
private static boolean hasCurrentUserObserverRole(HugeClient client,
@@ -540,10 +585,11 @@ public class UserService extends AuthService {
}
public void update(HugeClient hugeClient, UserEntity userEntity) {
- if (isPdEnabled() && hugeClient.supportsDefaultRole()) {
- this.graphSpaceUserService.validatePermissionPresets(hugeClient, userEntity.getGraphspacePermissions(),
- userEntity.getPermissionPreset());
- }
+ boolean permissionPresets = isPdEnabled() &&
+ hugeClient.supportsDefaultRole();
+ boolean permissionMutation = userEntity.getPermissionPreset() != null;
+ this.validatePermissionMutation(hugeClient, userEntity, false,
+ permissionPresets);
User user = new User();
user.setId(userEntity.getId());
user.name(userEntity.getName());
@@ -554,28 +600,115 @@ public class UserService extends AuthService {
if (isPdEnabled()) {
user.nickname(userEntity.getNickname());
}
- updateAdminSpace(hugeClient, userEntity.getName(), userEntity.getAdminSpaces());
-
- // 设置超级管理员权限
- boolean curSuperAdmin = isSuperAdmin(hugeClient, user.id().toString());
- if (curSuperAdmin && !userEntity.isSuperadmin()) {
- hugeClient.auth().delSuperAdmin(user.id().toString());
+ if (permissionPresets && permissionMutation) {
+ this.updateModernPermissionAccount(hugeClient, user, userEntity);
+ return;
}
- if (!curSuperAdmin && userEntity.isSuperadmin()) {
- hugeClient.auth().addSuperAdmin(user.id().toString());
+ if (!permissionPresets) {
+ updateAdminSpace(hugeClient, userEntity.getName(),
+ userEntity.getAdminSpaces());
+ }
+ if (!permissionPresets) {
+ String username = userEntity.getName();
+ boolean currentSuperAdmin =
+ isSuperAdmin(hugeClient, user.id().toString());
+ if (currentSuperAdmin && !userEntity.isSuperadmin()) {
+ hugeClient.auth().delSuperAdmin(username);
+ }
+ if (!currentSuperAdmin && userEntity.isSuperadmin()) {
+ hugeClient.auth().addSuperAdmin(username);
+ }
}
hugeClient.auth().updateUser(user);
- if (isPdEnabled() && hugeClient.supportsDefaultRole()) {
- this.graphSpaceUserService.applyPermissionPresets(hugeClient, userEntity.getName(),
+ }
+
+ private void updateModernPermissionAccount(HugeClient client, User user,
+ UserEntity userEntity) {
+ String userId = user.id().toString();
+ User previous = client.auth().getUser(userId);
+ E.checkNotNull(previous, "User");
+ String username = previous.name();
+ boolean previousSuperAdmin = client.auth().listSuperAdmin().contains(username);
+ try {
+ if (previousSuperAdmin && !userEntity.isSuperadmin()) {
+ client.auth().delSuperAdmin(username);
+ }
+ if (!previousSuperAdmin && userEntity.isSuperadmin()) {
+ client.auth().addSuperAdmin(username);
+ }
+ client.auth().updateUser(user);
+ this.graphSpaceUserService.applyPermissionPresets(
+ client, userEntity.getName(),
userEntity.getGraphspacePermissions(),
userEntity.getPermissionPreset());
+ } catch (RuntimeException error) {
+ this.restoreAccountProfile(client, previous, error);
+ this.restoreSuperAdmin(client, username, previousSuperAdmin, error);
+ throw error;
}
}
+ private void restoreAccountProfile(HugeClient client, User previous,
+ RuntimeException failure) {
+ try {
+ client.auth().updateUser(previous);
+ } catch (RuntimeException rollbackError) {
+ failure.addSuppressed(rollbackError);
+ log.warn("Failed to restore profile for account '{}'",
+ previous.id(), rollbackError);
+ }
+ }
+
+ private void restoreSuperAdmin(HugeClient client, String username,
+ boolean expected,
+ RuntimeException failure) {
+ try {
+ boolean current = client.auth().listSuperAdmin()
+ .contains(username);
+ if (expected && !current) {
+ client.auth().addSuperAdmin(username);
+ } else if (!expected && current) {
+ client.auth().delSuperAdmin(username);
+ }
+ } catch (RuntimeException rollbackError) {
+ failure.addSuppressed(rollbackError);
+ log.warn("Failed to restore super administrator state for '{}'",
+ username, rollbackError);
+ }
+ }
+
+ private void validatePermissionMutation(HugeClient client,
+ UserEntity user,
+ boolean create,
+ boolean supported) {
+ if (!supported) {
+ return;
+ }
+ String preset = user.getPermissionPreset();
+ if (preset == null) {
+ if (create) {
+ throw new ParameterizedException(
+ "auth.permission-preset.account-required");
+ }
+ return;
+ }
+ boolean superAdminPreset = "SUPER_ADMIN".equals(preset);
+ if (superAdminPreset != user.isSuperadmin()) {
+ throw new ParameterizedException(
+ "auth.permission-preset.superadmin-mismatch");
+ }
+ this.graphSpaceUserService.validatePermissionPresets(
+ client, user.getGraphspacePermissions(), preset);
+ }
+
public void updatePersonal(HugeClient hugeClient, String username,
String nickname, String description) {
- User user = hugeClient.findUserByName(username);
+ if (!hugeClient.supportsPersonalProfileUpdate()) {
+ throw new ParameterizedException(
+ "auth.profile.update-unsupported");
+ }
+ User user = currentUser(hugeClient, username);
if (isPdEnabled()) {
user.nickname(nickname);
} else {
@@ -602,7 +735,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.findUserByName(username);
+ User user = currentUser(hugeClient, username);
user.password(newpwd);
hugeClient.auth().updateUser(user);
return Response.builder()
@@ -610,6 +743,19 @@ public class UserService extends AuthService {
.build();
}
+ private static User currentUser(HugeClient client, String username) {
+ try {
+ return client.findCurrentUser(username);
+ } catch (IllegalStateException e) {
+ throw new UnauthorizedException();
+ } catch (ServerException e) {
+ if (e.status() == 401 || e.status() == 403 || e.status() == 404) {
+ throw new UnauthorizedException();
+ }
+ throw e;
+ }
+ }
+
public List<String> listAdminSpace(HugeClient hugeClient, String username) {
if (!isPdEnabled()) {
return new ArrayList<>();
@@ -652,6 +798,7 @@ public class UserService extends AuthService {
for (String adminspace : adminspaces) {
if (!oldadminspaces.contains(adminspace)) {
this.graphSpaceUserService.applySpacePreset(hugeClient, adminspace, account.id().toString(),
+ account.name(),
"GS_ADMIN");
}
}
@@ -694,9 +841,8 @@ public class UserService extends AuthService {
if (!isPdEnabled()) {
return false;
}
- // Only used by superadmin
- // Check: if user is spaceadmin for any graphspace
- return client.auth().listSuperAdmin().contains(uid);
+ User account = client.auth().getUser(uid);
+ return account != null && client.auth().listSuperAdmin().contains(account.name());
}
public boolean isSuperAdmin(HugeClient client) {
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 10e5d6a58..2b5afc2dc 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
@@ -19,13 +19,16 @@
package org.apache.hugegraph.service.space;
import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.hugegraph.api.task.TasksWithPage;
// TODO fix import
//import org.apache.hugegraph.client.api.task.TasksWithPage;
import org.apache.hugegraph.common.Constant;
import org.apache.hugegraph.driver.HugeClient;
import org.apache.hugegraph.entity.space.GraphSpaceEntity;
+import org.apache.hugegraph.exception.ExternalException;
import org.apache.hugegraph.exception.InternalException;
+import org.apache.hugegraph.exception.ServerException;
import org.apache.hugegraph.service.auth.UserService;
import org.apache.hugegraph.service.graphs.GraphsService;
import org.apache.hugegraph.structure.Task;
@@ -38,6 +41,7 @@ import org.apache.hugegraph.util.Log;
import org.apache.hugegraph.util.PageUtil;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.text.ParseException;
@@ -50,6 +54,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@@ -116,39 +121,63 @@ public class GraphSpaceService {
public IPage<Map<String, Object>> queryPage(HugeClient client, String query,
String createTime, int pageNo,
int pageSize) {
- List<Map<String, Object>> results =
- queryAllGs(client, query, createTime);
- return PageUtil.page(results, pageNo, pageSize);
+ return pageAndMap(queryAllGsMetadata(client, query, createTime),
+ pageNo, pageSize,
+ info -> graphSpaceView(client, info));
}
public List<Map<String, Object>> queryAllGs(HugeClient client, String query,
String createTime) {
- List<Map<String, Object>> results =
- client.graphSpace().listProfile(query).stream()
- .filter((s) -> s.get("create_time").toString()
- .compareTo(createTime) > 0)
- .collect(Collectors.toList());
- // 将DEFAULT和neizhianli的图空间排在前面, 其他图空间按字母序排序
- Collections.sort(results, (a, b) ->
- new BuiltInFirst().compare(a.get("name").toString(),
- b.get("name").toString()));
- for (Map<String, Object> info : results) {
- removeSensitiveFields(info);
- String name = info.get("name").toString();
- info.put("graphspace_admin",
- userService.listGraphSpaceAdmin(client, name));
- Map<String, Object> statisticTotal = evCount(client, name);
- info.put("statistic", statisticTotal);
- }
+ return queryAllGsMetadata(client, query, createTime).stream()
+ .map(info -> graphSpaceView(client, info))
+ .collect(Collectors.toList());
+ }
+
+ private List<Map<String, Object>> queryAllGsMetadata(
+ HugeClient client, String query, String createTime) {
+ List<Map<String, Object>> results = client.graphSpace()
+ .listProfile(query).stream()
+ .filter(info -> info.get("create_time").toString()
+ .compareTo(createTime) > 0)
+ .collect(Collectors.toList());
+ Collections.sort(results, (a, b) -> new BuiltInFirst().compare(
+ a.get("name").toString(), b.get("name").toString()));
return results;
}
+ private Map<String, Object> graphSpaceView(
+ HugeClient client, Map<String, Object> source) {
+ Map<String, Object> info = new HashMap<>(source);
+ removeSensitiveFields(info);
+ String name = info.get("name").toString();
+ info.put("graphspace_admin",
+ userService.listGraphSpaceAdmin(client, name));
+ info.put("statistic", evCount(client, name));
+ return info;
+ }
+
public List<Map<String, Object>> queryAccessibleGs(HugeClient client,
String query,
String createTime) {
+ return queryAccessibleSpaces(client, query, createTime).stream()
+ .map(space -> accessibleView(client, space))
+ .collect(Collectors.toList());
+ }
+
+ public IPage<Map<String, Object>> queryAccessibleGsPage(
+ HugeClient client, String query, String createTime,
+ int pageNo, int pageSize) {
+ return pageAndMap(queryAccessibleSpaces(client, query, createTime),
+ pageNo, pageSize,
+ space -> accessibleView(client, space));
+ }
+
+ private List<GraphSpace> queryAccessibleSpaces(HugeClient client,
+ String query,
+ String createTime) {
String prefix = query == null ? "" : query;
String after = createTime == null ? "" : createTime;
- List<Map<String, Object>> results = client.graphSpace()
+ List<GraphSpace> results = client.graphSpace()
.listGraphSpace().stream()
.map(client.graphSpace()::getGraphSpace)
.filter(space -> space != null &&
@@ -160,37 +189,93 @@ public class GraphSpaceService {
.filter(space -> !space.isAuth() ||
client.auth().isSpaceAdmin(space.getName()) ||
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()));
+ Collections.sort(results, (a, b) -> new BuiltInFirst().compare(
+ a.getName(), b.getName()));
return results;
}
+ private Map<String, Object> accessibleView(HugeClient client,
+ GraphSpace 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;
+ }
+
private static boolean hasCurrentUserAccess(HugeClient client,
String graphSpace) {
+ if (!client.supportsDefaultRole()) {
+ return client.auth().isSpaceMember(graphSpace);
+ }
if (client.auth().checkDefaultRole(graphSpace, "analyst")) {
return true;
}
- return client.supportsDefaultRole() &&
- client.auth().checkDefaultRole(graphSpace, "observer");
+ return client.auth().checkDefaultRole(graphSpace, "observer");
}
public List<Map<String, Object>> queryAnonymousGs(HugeClient client,
String query,
String createTime) {
+ return queryAnonymousSpaces(client, query, createTime).stream()
+ .map(space -> anonymousView(client, space))
+ .collect(Collectors.toList());
+ }
+
+ public List<String> listAnonymous(HugeClient client) {
+ return queryAnonymousSpaces(client, "", "").stream()
+ .map(GraphSpace::getName)
+ .collect(Collectors.toList());
+ }
+
+ public Map<String, Object> getAnonymous(HugeClient client,
+ String graphSpace) {
+ return anonymousView(client, publicSpace(client, graphSpace));
+ }
+
+ public boolean isAuthForAnonymous(HugeClient client, String graphSpace) {
+ return publicSpace(client, graphSpace).isAuth();
+ }
+
+ public IPage<Map<String, Object>> queryAnonymousGsPage(
+ HugeClient client, String query, String createTime,
+ int pageNo, int pageSize) {
+ List<GraphSpace> spaces =
+ queryAnonymousSpaces(client, query, createTime);
+ return pageAndMap(spaces, pageNo, pageSize,
+ space -> anonymousView(client, space));
+ }
+
+ static <S, T> IPage<T> pageAndMap(
+ List<S> sourceRecords, int pageNo, int pageSize,
+ Function<S, T> mapper) {
+ int boundedSize = PageUtil.boundedSize(pageSize);
+ if (pageSize == -1 && sourceRecords.size() > boundedSize) {
+ throw new IllegalArgumentException(
+ "GraphSpace list exceeds the maximum all-record limit of " +
+ boundedSize);
+ }
+ IPage<S> source = PageUtil.page(sourceRecords, pageNo, pageSize);
+ List<T> records = source.getRecords().stream()
+ .map(mapper)
+ .collect(Collectors.toList());
+ Page<T> result =
+ new Page<>(source.getCurrent(), source.getSize(),
+ sourceRecords.size(), true);
+ result.setRecords(records);
+ result.setOrders(Collections.emptyList());
+ result.setPages(source.getPages());
+ return result;
+ }
+
+ private List<GraphSpace> queryAnonymousSpaces(HugeClient client,
+ String query,
+ String createTime) {
String prefix = query == null ? "" : query;
String after = createTime == null ? "" : createTime;
- List<Map<String, Object>> results = client.graphSpace()
+ List<GraphSpace> results = client.graphSpace()
.listGraphSpace().stream()
.map(client.graphSpace()::getGraphSpace)
.filter(space -> space != null &&
@@ -198,22 +283,46 @@ public class GraphSpaceService {
space.getNickname() != null &&
space.getNickname().contains(prefix)))
.filter(space -> space.getCreateTime() == null || space.getCreateTime().compareTo(after) > 0)
- .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;
- })
+ .filter(space -> !space.isAuth())
.collect(Collectors.toList());
- Collections.sort(results, (a, b) ->
- new BuiltInFirst().compare(a.get("name").toString(),
- b.get("name").toString()));
+ Collections.sort(results, (a, b) -> new BuiltInFirst().compare(
+ a.getName(), b.getName()));
return results;
}
+ private Map<String, Object> anonymousView(HugeClient client,
+ GraphSpace 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;
+ }
+
+ private static GraphSpace publicSpace(HugeClient client,
+ String graphSpace) {
+ GraphSpace space;
+ try {
+ space = client.graphSpace().getGraphSpace(graphSpace);
+ } catch (ServerException e) {
+ if (e.status() == 400 || e.status() == 401 ||
+ e.status() == 403 || e.status() == 404) {
+ throw unavailableGraphSpace();
+ }
+ throw e;
+ }
+ if (space == null || space.isAuth()) {
+ throw unavailableGraphSpace();
+ }
+ return space;
+ }
+
+ private static ExternalException unavailableGraphSpace() {
+ return new ExternalException(HttpStatus.NOT_FOUND.value(),
+ "GraphSpace is unavailable");
+ }
+
public Map<String, Object> toView(GraphSpaceEntity entity) {
Map<String, Object> info = HubbleUtil.uncheckedCast(
JsonUtil.fromJson(JsonUtil.toJson(entity), Map.class));
diff --git a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties
index 8b9cfa5e7..60f221907 100644
--- a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties
+++ b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties
@@ -33,8 +33,14 @@ auth.user.import-file.failed=Failed to prepare the uploaded user import file
auth.user.import-csv.failed=Failed to parse the uploaded user CSV file
auth.permission-preset.unsupported=This server cannot apply GraphSpace permission presets. Connect Hubble to a distributed Server that supports GraphSpace permission presets.
auth.permission-preset.required=Custom GraphSpace role assignments are disabled on this Server. Use one of the permission presets shown in Hubble.
+auth.permission-preset.account-required=Choose an account permission preset.
+auth.permission-preset.superadmin-mismatch=The account permission preset does not match its global administrator flag.
+auth.permission-preset.graphspace-required=Choose at least one GraphSpace for this permission preset.
auth.permission-preset.graphspace-not-found=GraphSpace ''{0}'' is unavailable. Refresh the GraphSpace list and select an existing one.
auth.permission-preset.invalid=Permission preset ''{0}'' is unsupported. Choose a preset shown in the account form.
+auth.permission-preset.mismatch=GraphSpace permission preset ''{0}'' does not match account preset ''{1}''.
+auth.permission-preset.entry-invalid=GraphSpace permission entries must be objects.
+auth.profile.update-unsupported=This Server supports a read-only profile. Upgrade it to edit profile details.
common.name-time-order.conflict=The param name_order and time_order cannot set at same time
common.param.path-id-should-same-as-body=The id in path({0}) must be same as request body({1}) if it exists
diff --git a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties
index 7b92c919a..ba12d14c6 100644
--- a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties
+++ b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages_zh_CN.properties
@@ -33,8 +33,14 @@ auth.user.import-file.failed=无法准备上传的用户导入文件
auth.user.import-csv.failed=无法解析上传的用户 CSV 文件
auth.permission-preset.unsupported=当前 Server 无法应用 GraphSpace 权限预设。请将 Hubble 连接到支持 GraphSpace 权限预设的分布式 Server。
auth.permission-preset.required=当前 Server 已禁用 GraphSpace 自定义角色分配。请使用 Hubble 提供的权限预设。
+auth.permission-preset.account-required=请选择账号权限预设。
+auth.permission-preset.superadmin-mismatch=账号权限预设与全局管理员标记不一致。
+auth.permission-preset.graphspace-required=请为此权限预设至少选择一个 GraphSpace。
auth.permission-preset.graphspace-not-found=GraphSpace“{0}”不可用。请刷新 GraphSpace 列表后选择一个现有空间。
auth.permission-preset.invalid=不支持权限预设“{0}”。请选择账号表单中提供的预设。
+auth.permission-preset.mismatch=GraphSpace 权限预设“{0}”与账号权限预设“{1}”不一致。
+auth.permission-preset.entry-invalid=GraphSpace 权限条目必须是对象。
+auth.profile.update-unsupported=当前 Server 版本仅支持只读个人资料。请升级 Server 后再编辑资料。
common.name-time-order.conflict=参数 name_order 和 time_order 不能同时设置
common.param.path-id-should-same-as-body=当请求体中的 id({1}) 存在时,必须与路径中的 ({0}) 相同
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 3eaefb867..85b6dbcd7 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
@@ -504,6 +504,7 @@ public class GraphSpaceAuthOwnershipTest {
ReflectionTestUtils.setField(service, "belongService", belongs);
service.applySpacePreset(this.client, "SPACE_A", "graph-user",
+ "graph-user",
"GS_READ_ONLY");
Mockito.verify(this.auth).addSpaceMember("graph-user", "SPACE_A");
@@ -614,11 +615,12 @@ public class GraphSpaceAuthOwnershipTest {
mvc.perform(put("/api/v1.3/graphspaces/SPACE/auth/users/" +
"new-member/preset")
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"permission_preset\":\"GS_READ_WRITE\"}"))
+ .content("{\"username\":\"new-member\"," +
+ "\"permission_preset\":\"GS_READ_WRITE\"}"))
.andExpect(status().isOk());
Mockito.verify(members).applySpacePreset(
- this.client, "SPACE", "new-member", "GS_READ_WRITE");
+ this.client, "SPACE", null, "new-member", "GS_READ_WRITE");
Mockito.verify(this.auth, Mockito.never())
.getUser(Mockito.anyString());
}
@@ -645,7 +647,8 @@ public class GraphSpaceAuthOwnershipTest {
mvc.perform(put("/api/v1.3/graphspaces/SPACE/auth/users/" +
"global-admin/preset")
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"permission_preset\":\"GS_READ_WRITE\"}"))
+ .content("{\"username\":\"global-admin\"," +
+ "\"permission_preset\":\"GS_READ_WRITE\"}"))
.andExpect(status().isForbidden());
Mockito.verifyZeroInteractions(members);
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java
index cc256318b..7911be002 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/GraphSpaceControllerTest.java
@@ -39,6 +39,7 @@ import org.apache.hugegraph.exception.ForbiddenException;
import org.apache.hugegraph.handler.ExceptionAdvisor;
import org.apache.hugegraph.options.HubbleOptions;
import org.apache.hugegraph.service.auth.UserService;
+import org.apache.hugegraph.service.auth.AuthModeService;
import org.apache.hugegraph.service.graphs.GraphsService;
import org.apache.hugegraph.service.space.GraphSpaceService;
import org.apache.hugegraph.structure.space.GraphSpace;
@@ -107,6 +108,50 @@ public class GraphSpaceControllerTest {
Assert.assertFalse(detail.containsKey("configs"));
}
+ @Test
+ public void testAnonymousEndpointsUseOnlyPublicGraphSpaces() {
+ HugeClient client = Mockito.mock(HugeClient.class);
+ GraphSpaceService graphSpaceService = Mockito.mock(
+ GraphSpaceService.class);
+ TestGraphSpaceController controller =
+ new TestGraphSpaceController(client);
+ HugeConfig config = Mockito.mock(HugeConfig.class);
+ Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)).thenReturn(false);
+ AuthModeService authMode = new AuthModeService(config);
+ Mockito.when(graphSpaceService.listAnonymous(client))
+ .thenReturn(java.util.Collections.singletonList("public"));
+ Mockito.when(graphSpaceService.getAnonymous(client, "public"))
+ .thenReturn(java.util.Collections.singletonMap("name", "public"));
+ Mockito.when(graphSpaceService.isAuthForAnonymous(client, "public"))
+ .thenReturn(false);
+ controller.config = config;
+ ReflectionTestUtils.setField(controller, "authMode", authMode);
+ ReflectionTestUtils.setField(controller, "graphSpaceService",
+ graphSpaceService);
+
+ @SuppressWarnings("unchecked")
+ Map<String, Object> names = (Map<String, Object>) controller.list();
+ @SuppressWarnings("unchecked")
+ Map<String, Object> detail =
+ (Map<String, Object>) controller.get("public");
+ @SuppressWarnings("unchecked")
+ Map<String, Object> auth =
+ (Map<String, Object>) controller.isAuth("public");
+
+ Assert.assertEquals(java.util.Collections.singletonList("public"),
+ names.get("graphspaces"));
+ Assert.assertEquals("public", detail.get("name"));
+ Assert.assertEquals(false, auth.get("auth"));
+ Mockito.verify(graphSpaceService, Mockito.never()).listAll(client);
+ Mockito.verify(graphSpaceService, Mockito.never())
+ .isAuth(client, "public");
+ Mockito.verify(graphSpaceService, Mockito.never())
+ .getWithoutAdmins(client, "public");
+ Mockito.verify(graphSpaceService, Mockito.never())
+ .evCount(client, "public");
+ }
+
@Test
public void testApplyDefaultsForOptionalResourceLimits() {
GraphSpaceEntity graphSpace = new GraphSpaceEntity();
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/SchemaTemplateControllerSecurityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/SchemaTemplateControllerSecurityTest.java
index c0151aca4..aa31149ad 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/SchemaTemplateControllerSecurityTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/SchemaTemplateControllerSecurityTest.java
@@ -18,6 +18,8 @@
package org.apache.hugegraph.controller.space;
+import java.util.Collections;
+
import org.junit.Test;
import org.mockito.Mockito;
@@ -25,6 +27,9 @@ import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.driver.HugeClient;
import org.apache.hugegraph.exception.ForbiddenException;
import org.apache.hugegraph.options.HubbleOptions;
+import org.apache.hugegraph.service.auth.AuthModeService;
+import org.apache.hugegraph.service.auth.UserService;
+import org.apache.hugegraph.service.space.SchemaTemplateService;
import org.apache.hugegraph.structure.space.SchemaTemplate;
import org.apache.hugegraph.testutil.Assert;
@@ -32,7 +37,8 @@ public class SchemaTemplateControllerSecurityTest {
@Test
public void testCreateRequiresGraphSpaceWrite() {
- TestController controller = controller();
+ TestController controller = controller(true);
+ controller.denyWrite();
Assert.assertThrows(ForbiddenException.class, () ->
controller.create("space", Mockito.mock(SchemaTemplate.class)));
@@ -40,7 +46,8 @@ public class SchemaTemplateControllerSecurityTest {
@Test
public void testUpdateRequiresGraphSpaceWrite() {
- TestController controller = controller();
+ TestController controller = controller(true);
+ controller.denyWrite();
Assert.assertThrows(ForbiddenException.class, () ->
controller.update("space", "template",
@@ -49,26 +56,131 @@ public class SchemaTemplateControllerSecurityTest {
@Test
public void testDeleteRequiresGraphSpaceWrite() {
- TestController controller = controller();
+ TestController controller = controller(true);
+ controller.denyWrite();
+
+ Assert.assertThrows(ForbiddenException.class, () ->
+ controller.delete("space", "template"));
+ }
+
+ @Test
+ public void testOwnerCanUpdateAndDeleteTemplate() {
+ TestController controller = controller(true);
+ SchemaTemplate template = new SchemaTemplate("template", "schema");
+ Mockito.when(controller.schemaTemplateService.get(
+ controller.client, "template"))
+ .thenReturn(Collections.singletonMap("creator", "alice"));
+
+ controller.update("space", "template", template);
+ controller.delete("space", "template");
+
+ Mockito.verify(controller.schemaTemplateService)
+ .update(controller.client, template);
+ Mockito.verify(controller.schemaTemplateService)
+ .delete(controller.client, "template");
+ }
+ @Test
+ public void testWriterCannotMutateAnotherUsersTemplate() {
+ TestController controller = controller(true);
+ Mockito.when(controller.schemaTemplateService.get(
+ controller.client, "template"))
+ .thenReturn(Collections.singletonMap("creator", "bob"));
+
+ Assert.assertThrows(ForbiddenException.class, () ->
+ controller.update("space", "template",
+ Mockito.mock(SchemaTemplate.class)));
Assert.assertThrows(ForbiddenException.class, () ->
controller.delete("space", "template"));
+ Mockito.verify(controller.schemaTemplateService, Mockito.never())
+ .update(Mockito.any(), Mockito.any());
+ Mockito.verify(controller.schemaTemplateService, Mockito.never())
+ .delete(Mockito.any(), Mockito.anyString());
+ }
+
+ @Test
+ public void testGraphSpaceManagerCanMutateAnyTemplate() {
+ TestController controller = controller(true);
+ Mockito.when(controller.users.isAssignSpaceAdmin(
+ controller.client, "space"))
+ .thenReturn(true);
+
+ controller.delete("space", "template");
+
+ Mockito.verify(controller.schemaTemplateService)
+ .delete(controller.client, "template");
+ Mockito.verify(controller.schemaTemplateService, Mockito.never())
+ .get(Mockito.any(), Mockito.anyString());
}
- private static TestController controller() {
+ @Test
+ public void testGlobalAdministratorCanMutateAnyTemplate() {
+ TestController controller = controller(true);
+ Mockito.when(controller.users.isSuperAdmin(controller.client))
+ .thenReturn(true);
+
+ controller.delete("space", "template");
+
+ Mockito.verify(controller.schemaTemplateService)
+ .delete(controller.client, "template");
+ Mockito.verify(controller.schemaTemplateService, Mockito.never())
+ .get(Mockito.any(), Mockito.anyString());
+ }
+
+ @Test
+ public void testAnonymousModeKeepsTemplateMutationAvailable() {
+ TestController controller = controller(false);
+
+ controller.delete("space", "template");
+
+ Mockito.verify(controller.schemaTemplateService)
+ .delete(controller.client, "template");
+ Mockito.verifyZeroInteractions(controller.users);
+ }
+
+ private static TestController controller(boolean authEnabled) {
TestController controller = new TestController();
HugeConfig config = Mockito.mock(HugeConfig.class);
Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(config.get(HubbleOptions.AUTH_ENABLED))
+ .thenReturn(authEnabled);
controller.config = config;
+ controller.setAuthMode(new AuthModeService(config));
+ controller.schemaTemplateService =
+ Mockito.mock(SchemaTemplateService.class);
return controller;
}
private static class TestController extends SchemaTemplateController {
+ private final HugeClient client = Mockito.mock(HugeClient.class);
+ private final UserService users = Mockito.mock(UserService.class);
+ private boolean writeAllowed = true;
+
+ private TestController() {
+ this.userService = this.users;
+ }
+
+ public void setAuthMode(AuthModeService authMode) {
+ this.authMode = authMode;
+ }
+
+ public void denyWrite() {
+ this.writeAllowed = false;
+ }
+
@Override
protected HugeClient requireGraphSpaceWrite(String graphSpace) {
- throw new ForbiddenException(
- "Permission denied: write graphspace resources");
+ if (!this.writeAllowed) {
+ throw new ForbiddenException(
+ "Permission denied: write graphspace resources");
+ }
+ return this.client;
+ }
+
+ @Override
+ protected String getUser() {
+ return "alice";
}
}
}
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 7f1b082b3..1091906ff 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
@@ -108,6 +108,8 @@ public class AuthContextServiceTest {
public void testPdServerWithoutPresetApiHidesScopedMutations() {
Fixture fixture = new Fixture(true);
Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(false);
+ Mockito.when(fixture.client.supportsPersonalProfileUpdate())
+ .thenReturn(false);
Mockito.when(fixture.users.getpersonal(fixture.client, "alice"))
.thenReturn(user(true, Collections.singletonList("space-a")));
@@ -121,6 +123,8 @@ public class AuthContextServiceTest {
Assert.assertTrue(actions(context, "members").isEmpty());
Assert.assertTrue(actions(context, "roles").isEmpty());
Assert.assertTrue(actions(context, "authorizations").isEmpty());
+ Assert.assertEquals(Set.of("read", "change_password"),
+ actions(context, "account"));
}
@Test
@@ -342,6 +346,8 @@ public class AuthContextServiceTest {
private Fixture(boolean pdEnabled) {
Mockito.when(this.config.get(HubbleOptions.PD_ENABLED))
.thenReturn(pdEnabled);
+ Mockito.when(this.client.supportsPersonalProfileUpdate())
+ .thenReturn(true);
this.service = new AuthContextService(this.config, this.users);
}
}
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java
index a9489d97c..080150e0a 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java
@@ -17,6 +17,7 @@
package org.apache.hugegraph.service.auth;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
@@ -78,6 +79,80 @@ public class GraphSpaceUserServiceTest {
error.getMessage());
}
+ @Test
+ public void testGraphSpacePresetRequiresAtLeastOneGraphSpace() {
+ for (String preset : Arrays.asList(
+ "GS_READ_ONLY", "GS_READ_WRITE", "GS_ADMIN")) {
+ ParameterizedException error = null;
+ try {
+ this.service.validatePermissionPresets(
+ this.client, Collections.emptyList(), preset);
+ } catch (ParameterizedException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertEquals(
+ "auth.permission-preset.graphspace-required",
+ error.getMessage());
+ }
+ Mockito.verifyZeroInteractions(this.graphSpace);
+ }
+
+ @Test
+ public void testUnknownAccountPresetIsRejectedBeforeAccountCreation() {
+ ParameterizedException error = null;
+ try {
+ this.service.validatePermissionPresets(
+ this.client, Collections.emptyList(), "UNKNOWN");
+ } catch (ParameterizedException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertEquals("auth.permission-preset.invalid",
+ error.getMessage());
+ Mockito.verifyZeroInteractions(this.graphSpace);
+ }
+
+ @Test
+ public void testMixedGraphSpacePresetsAreRejected() {
+ Mockito.when(this.graphSpace.listGraphSpace())
+ .thenReturn(Collections.singletonList("team"));
+ Map<String, String> permission = new HashMap<>();
+ permission.put("graphspace", "team");
+ permission.put("permission_preset", "GS_READ_ONLY");
+
+ ParameterizedException error = null;
+ try {
+ this.service.validatePermissionPresets(
+ this.client, Collections.singletonList(permission),
+ "GS_READ_WRITE");
+ } catch (ParameterizedException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertEquals("auth.permission-preset.mismatch",
+ error.getMessage());
+ }
+
+ @Test
+ public void testNullGraphSpacePermissionEntryIsRejected() {
+ ParameterizedException error = null;
+ try {
+ this.service.validatePermissionPresets(
+ this.client, Collections.singletonList(null),
+ "GS_READ_ONLY");
+ } catch (ParameterizedException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertEquals("auth.permission-preset.entry-invalid",
+ error.getMessage());
+ }
+
@Test
public void testListUsersMarksOnlyServerDefaultRolesAsPresets() {
User user = user("u-1", "alice");
@@ -185,6 +260,102 @@ public class GraphSpaceUserServiceTest {
Mockito.verify(this.auth).delSpaceMember("alice", "old");
}
+ @Test
+ public void testNewAccountAppliesOnlyRequestedSpaces() {
+ User user = user("u-1", "alice");
+ Mockito.when(this.client.findUserByName("alice")).thenReturn(user);
+ GraphSpaceUserService service = Mockito.spy(this.service);
+ Mockito.doNothing().when(service)
+ .applySpacePreset(this.client, "team", "u-1",
+ "alice",
+ "GS_READ_ONLY");
+
+ service.applyPermissionPresetsForNewAccount(
+ this.client, "alice",
+ Collections.singletonList(permission("team", "GS_READ_ONLY")),
+ "GS_READ_ONLY");
+
+ Mockito.verify(service).applySpacePreset(
+ this.client, "team", "u-1", "alice", "GS_READ_ONLY");
+ Mockito.verify(this.graphSpace, Mockito.never()).listGraphSpace();
+ }
+
+ @Test
+ public void testReconciliationRestoresEarlierGraphSpaces() {
+ User user = user("u-1", "alice");
+ Mockito.when(this.client.findUserByName("alice")).thenReturn(user);
+ Mockito.when(this.graphSpace.listGraphSpace())
+ .thenReturn(Arrays.asList("first", "second"));
+ Mockito.when(this.auth.listSpaceMember("first"))
+ .thenReturn(Collections.singletonList("alice"))
+ .thenReturn(Collections.emptyList());
+ Mockito.when(this.auth.listSpaceMember("second"))
+ .thenReturn(Collections.singletonList("alice"));
+ Mockito.when(this.belongService.list(
+ Mockito.eq(this.client), Mockito.anyString(),
+ Mockito.isNull(), Mockito.eq("u-1")))
+ .thenReturn(Collections.emptyList());
+ Mockito.when(this.graphSpace.checkDefaultRole(
+ "first", "alice", "analyst"))
+ .thenReturn(false);
+ Mockito.when(this.graphSpace.checkDefaultRole(
+ "first", "alice", "observer"))
+ .thenReturn(true, false);
+ Mockito.when(this.graphSpace.checkDefaultRole(
+ "second", "alice", "analyst"))
+ .thenReturn(false);
+ Mockito.when(this.graphSpace.checkDefaultRole(
+ "second", "alice", "observer"))
+ .thenReturn(false);
+ Mockito.when(this.auth.listSpaceAdmin(Mockito.anyString()))
+ .thenReturn(Collections.emptyList());
+ GraphSpaceUserService service = Mockito.spy(this.service);
+ Mockito.doNothing().when(service)
+ .applySpacePreset(this.client, "first", "u-1",
+ "alice",
+ "GS_READ_ONLY");
+ RuntimeException failure = new RuntimeException("second failed");
+ Mockito.doThrow(failure).when(service)
+ .applySpacePreset(this.client, "second", "u-1",
+ "alice",
+ "GS_READ_ONLY");
+
+ Throwable error = Assert.assertThrows(
+ RuntimeException.class,
+ () -> service.applyPermissionPresets(
+ this.client, "alice",
+ Arrays.asList(
+ permission("first", "GS_READ_ONLY"),
+ permission("second", "GS_READ_ONLY")),
+ "GS_READ_ONLY"));
+
+ Assert.assertSame(failure, error);
+ InOrder rollback = Mockito.inOrder(this.auth, this.graphSpace);
+ rollback.verify(this.auth).addSpaceMember("alice", "first");
+ rollback.verify(this.graphSpace)
+ .setDefaultRole("first", "alice", "observer");
+ }
+
+ @Test
+ public void testNewMemberResolvesIdAfterUsernameMembership() {
+ User user = user("u-1", "alice");
+ Mockito.when(this.auth.listSpaceMember("team"))
+ .thenReturn(Collections.emptyList());
+ Mockito.when(this.client.findUserByName("alice")).thenReturn(user);
+ Mockito.when(this.belongService.list(
+ this.client, "team", null, "u-1"))
+ .thenReturn(Collections.emptyList());
+ Mockito.when(this.auth.listSpaceAdmin("team"))
+ .thenReturn(Collections.emptyList());
+
+ this.service.applySpacePreset(this.client, "team", null, "alice", "GS_READ_ONLY");
+
+ Mockito.verify(this.auth).addSpaceMember("alice", "team");
+ Mockito.verify(this.client).findUserByName("alice");
+ Mockito.verify(this.graphSpace)
+ .setDefaultRole("team", "alice", "observer");
+ }
+
@Test
public void testApplyAdminPresetAddsManagementAndWriteAccess() {
User user = user("alice", "alice");
@@ -198,6 +369,7 @@ public class GraphSpaceUserServiceTest {
.thenReturn(Collections.emptyList());
this.service.applySpacePreset(this.client, "team", "alice",
+ "alice",
"GS_ADMIN");
Mockito.verify(this.auth).addSpaceMember("alice", "team");
@@ -219,6 +391,7 @@ public class GraphSpaceUserServiceTest {
.thenReturn(Collections.singletonList("alice"));
this.service.applySpacePreset(this.client, "team", "alice",
+ "alice",
"GS_READ_WRITE");
Mockito.verify(this.auth).delSpaceAdmin("alice", "team");
@@ -241,6 +414,7 @@ public class GraphSpaceUserServiceTest {
.thenReturn(Collections.emptyList());
this.service.applySpacePreset(this.client, "team", "alice",
+ "alice",
"GS_READ_ONLY");
InOrder order = Mockito.inOrder(this.auth);
@@ -261,6 +435,7 @@ public class GraphSpaceUserServiceTest {
Assert.assertThrows(RuntimeException.class,
() -> this.service.applySpacePreset(
this.client, "team", "alice",
+ "alice",
"GS_READ_ONLY"));
InOrder order = Mockito.inOrder(this.auth);
@@ -280,6 +455,7 @@ public class GraphSpaceUserServiceTest {
Assert.assertThrows(RuntimeException.class,
() -> this.service.applySpacePreset(
this.client, "team", "alice",
+ "alice",
"GS_READ_ONLY"));
Mockito.verify(this.auth).delSpaceMember("alice", "team");
@@ -305,6 +481,7 @@ public class GraphSpaceUserServiceTest {
Assert.assertThrows(RuntimeException.class,
() -> this.service.applySpacePreset(
this.client, "team", "alice",
+ "alice",
"GS_READ_ONLY"));
InOrder order = Mockito.inOrder(this.auth, this.graphSpace);
@@ -335,6 +512,7 @@ public class GraphSpaceUserServiceTest {
Assert.assertThrows(RuntimeException.class,
() -> this.service.applySpacePreset(
this.client, "team", "alice",
+ "alice",
"GS_ADMIN"));
InOrder order = Mockito.inOrder(this.auth, this.graphSpace);
@@ -379,6 +557,7 @@ public class GraphSpaceUserServiceTest {
Assert.assertThrows(RuntimeException.class,
() -> this.service.applySpacePreset(
this.client, "team", "alice",
+ "alice",
"GS_READ_WRITE"));
Mockito.verify(this.belongService)
@@ -429,6 +608,7 @@ public class GraphSpaceUserServiceTest {
Assert.assertThrows(RuntimeException.class,
() -> this.service.applySpacePreset(
this.client, "team", "alice",
+ "alice",
"GS_READ_ONLY"));
Mockito.verify(this.belongService)
@@ -462,6 +642,7 @@ public class GraphSpaceUserServiceTest {
Assert.assertThrows(RuntimeException.class,
() -> this.service.applySpacePreset(
this.client, "team", "alice",
+ "alice",
"GS_READ_ONLY"));
Mockito.verify(this.graphSpace)
diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java
index 4a05ea346..6c051e7ba 100644
--- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java
+++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/space/GraphSpaceServiceTest.java
@@ -21,14 +21,21 @@ import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.hugegraph.driver.AuthManager;
import org.apache.hugegraph.driver.GraphSpaceManager;
import org.apache.hugegraph.driver.HugeClient;
import org.apache.hugegraph.entity.space.GraphSpaceEntity;
+import org.apache.hugegraph.exception.ExternalException;
+import org.apache.hugegraph.exception.ServerException;
import org.apache.hugegraph.service.auth.UserService;
import org.apache.hugegraph.service.graphs.GraphsService;
import org.apache.hugegraph.structure.space.GraphSpace;
+import org.apache.hugegraph.util.PageUtil;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
@@ -151,7 +158,7 @@ public class GraphSpaceServiceTest {
}
@Test
- public void testLegacyAnalystGraphSpaceRemainsVisible() {
+ public void testLegacyMemberGraphSpaceRemainsVisible() {
GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
AuthManager auth = Mockito.mock(AuthManager.class);
GraphSpace space = graphSpace("legacy", true, "20260712");
@@ -161,8 +168,7 @@ public class GraphSpaceServiceTest {
Mockito.when(manager.listGraphSpace())
.thenReturn(java.util.Collections.singletonList("legacy"));
Mockito.when(manager.getGraphSpace("legacy")).thenReturn(space);
- Mockito.when(auth.checkDefaultRole("legacy", "analyst"))
- .thenReturn(true);
+ Mockito.when(auth.isSpaceMember("legacy")).thenReturn(true);
Mockito.when(this.graphsService.listGraphNames(this.client, "legacy",
""))
.thenReturn(java.util.Collections.emptySet());
@@ -173,7 +179,7 @@ public class GraphSpaceServiceTest {
Assert.assertEquals(1, response.size());
Assert.assertEquals("legacy", response.get(0).get("name"));
Mockito.verify(auth, Mockito.never())
- .checkDefaultRole("legacy", "observer");
+ .checkDefaultRole(Mockito.anyString(), Mockito.anyString());
}
@Test
@@ -201,6 +207,154 @@ public class GraphSpaceServiceTest {
Assert.assertTrue((Boolean) response.get(0).get("authed"));
}
+ @Test
+ public void testAnonymousGraphSpacesExcludeProtectedSpaces() {
+ GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+ GraphSpace visible = graphSpace("public", false, "20260712");
+ GraphSpace protectedSpace = graphSpace("protected", true, "20260712");
+ Mockito.when(this.client.graphSpace()).thenReturn(manager);
+ Mockito.when(manager.listGraphSpace())
+ .thenReturn(java.util.Arrays.asList("public", "protected"));
+ Mockito.when(manager.getGraphSpace("public")).thenReturn(visible);
+ Mockito.when(manager.getGraphSpace("protected"))
+ .thenReturn(protectedSpace);
+ Mockito.when(this.graphsService.listGraphNames(this.client, "public", ""))
+ .thenReturn(java.util.Collections.emptySet());
+
+ List<Map<String, Object>> response =
+ this.service.queryAnonymousGs(this.client, "", "");
+
+ Assert.assertEquals(1, response.size());
+ Assert.assertEquals("public", response.get(0).get("name"));
+ Assert.assertEquals(java.util.Collections.singletonList("public"),
+ this.service.listAnonymous(this.client));
+ Mockito.verify(this.graphsService, Mockito.never())
+ .listGraphNames(this.client, "protected", "");
+ }
+
+ @Test
+ public void testAnonymousDetailRejectsProtectedSpaceBeforeStatistics() {
+ GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+ GraphSpace protectedSpace = graphSpace("protected", true, "20260712");
+ Mockito.when(this.client.graphSpace()).thenReturn(manager);
+ Mockito.when(manager.getGraphSpace("protected"))
+ .thenReturn(protectedSpace);
+
+ try {
+ this.service.getAnonymous(this.client, "protected");
+ Assert.fail("Expected protected GraphSpace to be unavailable");
+ } catch (ExternalException e) {
+ Assert.assertEquals(404, e.status());
+ }
+ try {
+ this.service.isAuthForAnonymous(this.client, "protected");
+ Assert.fail("Expected protected GraphSpace auth to be unavailable");
+ } catch (ExternalException e) {
+ Assert.assertEquals(404, e.status());
+ }
+ Mockito.verifyZeroInteractions(this.graphsService);
+ }
+
+ @Test
+ public void testAnonymousAuthHidesMissingGraphSpaceLikeProtectedSpace() {
+ GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+ Mockito.when(this.client.graphSpace()).thenReturn(manager);
+ ServerException missing = new ServerException("missing");
+ missing.status(400);
+ Mockito.when(manager.getGraphSpace("missing")).thenThrow(missing);
+
+ try {
+ this.service.isAuthForAnonymous(this.client, "missing");
+ Assert.fail("Expected missing GraphSpace auth to be unavailable");
+ } catch (ExternalException e) {
+ Assert.assertEquals(404, e.status());
+ }
+ try {
+ this.service.getAnonymous(this.client, "missing");
+ Assert.fail("Expected missing GraphSpace detail to be unavailable");
+ } catch (ExternalException e) {
+ Assert.assertEquals(404, e.status());
+ }
+ }
+
+ @Test
+ public void testAnonymousGraphSpacesCollectStatisticsAfterPaging() {
+ GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+ Mockito.when(this.client.graphSpace()).thenReturn(manager);
+ Mockito.when(manager.listGraphSpace())
+ .thenReturn(java.util.Arrays.asList("a", "b", "c"));
+ Mockito.when(manager.getGraphSpace(Mockito.anyString()))
+ .thenAnswer(invocation -> graphSpace(
+ invocation.getArgument(0), false, "20260712"));
+ Mockito.when(this.graphsService.listGraphNames(
+ Mockito.eq(this.client), Mockito.anyString(),
+ Mockito.eq("")))
+ .thenReturn(java.util.Collections.emptySet());
+
+ IPage<Map<String, Object>> response =
+ this.service.queryAnonymousGsPage(this.client, "", "",
+ 2, 1);
+
+ Assert.assertEquals(3L, response.getTotal());
+ Assert.assertEquals(1, response.getRecords().size());
+ Assert.assertEquals("b", response.getRecords().get(0).get("name"));
+ Mockito.verify(this.graphsService)
+ .listGraphNames(this.client, "b", "");
+ Mockito.verify(this.graphsService, Mockito.never())
+ .listGraphNames(this.client, "a", "");
+ Mockito.verify(this.graphsService, Mockito.never())
+ .listGraphNames(this.client, "c", "");
+ }
+
+ @Test
+ public void testAccessibleGraphSpacesCollectStatisticsAfterPaging() {
+ GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class);
+ AuthManager auth = Mockito.mock(AuthManager.class);
+ Mockito.when(this.client.graphSpace()).thenReturn(manager);
+ Mockito.when(this.client.auth()).thenReturn(auth);
+ Mockito.when(manager.listGraphSpace())
+ .thenReturn(java.util.Arrays.asList("a", "b", "c"));
+ Mockito.when(manager.getGraphSpace(Mockito.anyString()))
+ .thenAnswer(invocation -> graphSpace(
+ invocation.getArgument(0), false, "20260712"));
+ Mockito.when(this.graphsService.listGraphNames(
+ Mockito.eq(this.client), Mockito.anyString(),
+ Mockito.eq("")))
+ .thenReturn(java.util.Collections.emptySet());
+
+ IPage<Map<String, Object>> response =
+ this.service.queryAccessibleGsPage(this.client, "", "",
+ 2, 1);
+
+ Assert.assertEquals(3L, response.getTotal());
+ Assert.assertEquals("b", response.getRecords().get(0).get("name"));
+ Mockito.verify(this.graphsService)
+ .listGraphNames(this.client, "b", "");
+ Mockito.verify(this.graphsService, Mockito.never())
+ .listGraphNames(this.client, "a", "");
+ Mockito.verify(this.graphsService, Mockito.never())
+ .listGraphNames(this.client, "c", "");
+ }
+
+ @Test
+ public void testGraphSpaceAllSentinelUsesHardCap() {
+ List<Integer> values = IntStream.rangeClosed(0, PageUtil.HARD_CAP)
+ .boxed()
+ .collect(Collectors.toList());
+ AtomicInteger mapped = new AtomicInteger();
+
+ try {
+ GraphSpaceService.pageAndMap(values, 1, -1, value -> {
+ mapped.incrementAndGet();
+ return value;
+ });
+ Assert.fail("Expected the all-record hard cap to reject the request");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue(e.getMessage().contains("10000"));
+ }
+ Assert.assertEquals(0, mapped.get());
+ }
+
@Test
public void testStatisticUsesActualFallbackDate() {
Mockito.when(this.graphsService.listGraphNames(this.client, "space", ""))
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 e947eb2a2..1f0568c98 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
@@ -203,6 +203,22 @@ public class AuthSecurityTest {
MockHttpServletRequest context = new MockHttpServletRequest("GET", "/api/v1.3/auth/context");
Assert.assertTrue(interceptor.preHandle(context, new MockHttpServletResponse(), null));
+
+ MockHttpServletRequest scopedUsers = new MockHttpServletRequest(
+ "GET", "/api/v1.3/graphspaces/DEFAULT/auth/users");
+ assertThrows(ExternalException.class, () ->
+ interceptor.preHandle(scopedUsers,
+ new MockHttpServletResponse(), null));
+
+ MockHttpServletRequest authGraph = new MockHttpServletRequest(
+ "GET", "/api/v1.3/graphspaces/DEFAULT/graphs/auth/schema");
+ Assert.assertTrue(interceptor.preHandle(
+ authGraph, new MockHttpServletResponse(), null));
+
+ MockHttpServletRequest authGraphSpace = new MockHttpServletRequest(
+ "GET", "/api/v1.3/graphspaces/auth/graphs/hugegraph/schema");
+ Assert.assertTrue(interceptor.preHandle(
+ authGraphSpace, new MockHttpServletResponse(), null));
}
@Test
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 24a9947d3..a3a796181 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
@@ -19,6 +19,8 @@ package org.apache.hugegraph.unit;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.junit.Assert;
@@ -28,13 +30,16 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
+import org.apache.hugegraph.common.Response;
import org.apache.hugegraph.config.HugeConfig;
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.UserEntity;
+import org.apache.hugegraph.exception.ParameterizedException;
import org.apache.hugegraph.exception.ServerException;
+import org.apache.hugegraph.exception.UnauthorizedException;
import org.apache.hugegraph.options.HubbleOptions;
import org.apache.hugegraph.service.auth.GraphSpaceUserService;
import org.apache.hugegraph.service.auth.UserService;
@@ -126,9 +131,9 @@ public class UserServiceCompatibilityTest {
@Test
public void testStandalonePersonalUpdateOmitsPdOnlyNickname() {
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"));
+ Mockito.when(this.client.supportsPersonalProfileUpdate())
+ .thenReturn(true);
+ Mockito.when(this.client.findCurrentUser("user")).thenReturn(user("user"));
this.service.updatePersonal(this.client, "user", "display-name",
"description");
@@ -139,11 +144,77 @@ public class UserServiceCompatibilityTest {
Assert.assertEquals("description", request.getValue().description());
}
+ @Test
+ public void testLegacyPersonalUpdateFailsBeforeServerWrite() {
+ Mockito.when(this.client.supportsPersonalProfileUpdate())
+ .thenReturn(false);
+
+ ParameterizedException error = null;
+ try {
+ this.service.updatePersonal(this.client, "user", "display-name",
+ "description");
+ } catch (ParameterizedException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertEquals("auth.profile.update-unsupported",
+ error.getMessage());
+ Mockito.verify(this.client, Mockito.never())
+ .findCurrentUser(Mockito.anyString());
+ Mockito.verify(this.auth, Mockito.never())
+ .updateUser(Mockito.any(User.class));
+ }
+
+ @Test
+ public void testLegacyPasswordUpdateUsesVerifiedIdentity() {
+ User current = user("user");
+ current.setId("user-id");
+ Mockito.when(this.client.findCurrentUser("user"))
+ .thenReturn(current);
+
+ Response response = this.service.updatepwd(
+ this.client, "user", "old-password", "new-password");
+
+ Assert.assertEquals(200, response.getStatus());
+ ArgumentCaptor<User> request = ArgumentCaptor.forClass(User.class);
+ Mockito.verify(this.auth).updateUser(request.capture());
+ Assert.assertEquals("user-id", request.getValue().id());
+ Assert.assertEquals("new-password", request.getValue().password());
+ }
+
+ @Test
+ public void testCurrentUserIdentityMismatchIsUnauthorized() {
+ Mockito.when(this.client.findCurrentUser("user"))
+ .thenThrow(new IllegalStateException("mismatch"));
+
+ try {
+ this.service.getpersonal(this.client, "user");
+ Assert.fail("Expected an unauthorized current-user identity");
+ } catch (UnauthorizedException ignored) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testMissingCurrentUserRecordIsUnauthorized() {
+ ServerException missing = new ServerException("missing");
+ missing.status(404);
+ Mockito.when(this.client.findCurrentUser("user")).thenThrow(missing);
+
+ try {
+ this.service.getpersonal(this.client, "user");
+ Assert.fail("Expected an unauthorized missing current user");
+ } catch (UnauthorizedException ignored) {
+ // Expected
+ }
+ }
+
@Test
public void testCurrentUserPresetUsesSelfPermissionApi() {
Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
- Mockito.when(this.client.findUserByName("user"))
+ Mockito.when(this.client.findCurrentUser("user"))
.thenReturn(user("user"));
Mockito.when(this.graphSpace.listGraphSpace())
.thenReturn(java.util.Collections.singletonList("SPACE"));
@@ -184,7 +255,7 @@ public class UserServiceCompatibilityTest {
public void testCurrentUserIgnoresOnlyForbiddenGraphSpaces() {
Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
- Mockito.when(this.client.findUserByName("user"))
+ Mockito.when(this.client.findCurrentUser("user"))
.thenReturn(user("user"));
Mockito.when(this.graphSpace.listGraphSpace())
.thenReturn(Arrays.asList("OWNED", "DENIED"));
@@ -202,15 +273,14 @@ public class UserServiceCompatibilityTest {
}
@Test
- public void testLegacyAnalystKeepsGraphSpaceAccess() {
+ public void testLegacySpaceMemberKeepsGraphSpaceAccess() {
Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
Mockito.when(this.client.supportsDefaultRole()).thenReturn(false);
- Mockito.when(this.client.findUserByName("user"))
+ Mockito.when(this.client.findCurrentUser("user"))
.thenReturn(user("user"));
Mockito.when(this.graphSpace.listGraphSpace())
.thenReturn(Collections.singletonList("SPACE"));
- Mockito.when(this.auth.checkDefaultRole("SPACE", "analyst"))
- .thenReturn(true);
+ Mockito.when(this.auth.isSpaceMember("SPACE")).thenReturn(true);
UserEntity result = this.service.getpersonal(this.client, "user");
@@ -218,7 +288,7 @@ public class UserServiceCompatibilityTest {
result.getResSpaces());
Assert.assertEquals("LEGACY_CUSTOM", result.getPermissionPreset());
Mockito.verify(this.auth, Mockito.never())
- .checkDefaultRole("SPACE", "observer");
+ .checkDefaultRole(Mockito.anyString(), Mockito.anyString());
}
@Test
@@ -246,10 +316,6 @@ public class UserServiceCompatibilityTest {
public void testModernProfileUpdatePreservesPermissionAssignments() {
Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
- GraphSpaceUserService actualGraphSpaceUsers =
- new GraphSpaceUserService();
- ReflectionTestUtils.setField(this.service, "graphSpaceUserService",
- actualGraphSpaceUsers);
UserEntity user = UserEntity.builder()
.id("user-id")
.name("user")
@@ -259,43 +325,89 @@ public class UserServiceCompatibilityTest {
this.service.update(this.client, user);
Mockito.verify(this.auth).updateUser(Mockito.any(User.class));
- Mockito.verify(this.graphSpace, Mockito.never()).setDefaultRole(
- Mockito.anyString(), Mockito.anyString(), Mockito.anyString());
- Mockito.verify(this.graphSpace, Mockito.never()).deleteDefaultRole(
- Mockito.anyString(), Mockito.anyString(), Mockito.anyString());
- Mockito.verify(this.auth, Mockito.never()).addSpaceAdmin(
- Mockito.anyString(), Mockito.anyString());
- Mockito.verify(this.auth, Mockito.never()).delSpaceAdmin(
- Mockito.anyString(), Mockito.anyString());
+ Mockito.verify(this.graphSpaceUsers, Mockito.never())
+ .validatePermissionPresets(Mockito.any(), Mockito.any(),
+ Mockito.any());
+ Mockito.verify(this.graphSpaceUsers, Mockito.never())
+ .applyPermissionPresets(Mockito.any(), Mockito.anyString(),
+ Mockito.any(), Mockito.any());
+ Mockito.verify(this.auth, Mockito.never()).listSuperAdmin();
+ Mockito.verify(this.auth, Mockito.never())
+ .addSuperAdmin(Mockito.anyString());
+ Mockito.verify(this.auth, Mockito.never())
+ .delSuperAdmin(Mockito.anyString());
}
@Test
- public void testModernUserUpdateReconcilesAdminSpaces() {
+ public void testModernUserUpdateUsesPresetAsPermissionSource() {
Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
- Mockito.when(this.graphSpace.listGraphSpace())
- .thenReturn(Arrays.asList("OLD", "NEW"));
- Mockito.when(this.auth.listSpaceAdmin("OLD"))
- .thenReturn(Collections.singletonList("user"));
- Mockito.when(this.auth.listSpaceAdmin("NEW"))
- .thenReturn(Collections.emptyList());
- Mockito.when(this.client.findUserByName("user"))
- .thenReturn(user("user"));
+ Mockito.when(this.auth.getUser("user")).thenReturn(user("user"));
+ Map<String, String> permission = new HashMap<>();
+ permission.put("graphspace", "NEW");
+ permission.put("permission_preset", "GS_ADMIN");
UserEntity account = UserEntity.builder()
.id("user")
.name("user")
.adminSpaces(
Collections.singletonList("NEW"))
.build();
- account.setSuperadmin(true);
+ account.setPermissionPreset("GS_ADMIN");
+ account.setGraphspacePermissions(
+ Collections.singletonList(permission));
this.service.update(this.client, account);
- Mockito.verify(this.graphSpaceUsers).applySpacePreset(
- this.client, "NEW", "user", "GS_ADMIN");
- Mockito.verify(this.graphSpaceUsers).removeSpacePreset(
- this.client, "OLD", "user");
- Mockito.verify(this.auth).addSuperAdmin("user");
+ Mockito.verify(this.graphSpaceUsers).validatePermissionPresets(
+ this.client, account.getGraphspacePermissions(), "GS_ADMIN");
+ Mockito.verify(this.graphSpaceUsers).applyPermissionPresets(
+ this.client, "user", account.getGraphspacePermissions(),
+ "GS_ADMIN");
+ Mockito.verify(this.auth, Mockito.never())
+ .addSpaceAdmin(Mockito.anyString(), Mockito.anyString());
+ Mockito.verify(this.auth, Mockito.never())
+ .addSuperAdmin(Mockito.anyString());
+ }
+
+ @Test
+ public void testModernUserUpdateRollsBackProfileAndSuperAdmin() {
+ Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
+ User previous = user("alice");
+ previous.setId("u-1");
+ previous.description("old");
+ Mockito.when(this.auth.getUser("u-1")).thenReturn(previous);
+ Mockito.when(this.auth.listSuperAdmin())
+ .thenReturn(Collections.singletonList("alice"))
+ .thenReturn(Collections.emptyList());
+ UserEntity account = UserEntity.builder()
+ .id("u-1")
+ .name("alice")
+ .description("new")
+ .build();
+ account.setPermissionPreset("GS_READ_ONLY");
+ account.setGraphspacePermissions(Collections.singletonList(
+ permission("team", "GS_READ_ONLY")));
+ RuntimeException failure = new RuntimeException("preset failed");
+ Mockito.doThrow(failure).when(this.graphSpaceUsers)
+ .applyPermissionPresets(
+ this.client, "alice",
+ account.getGraphspacePermissions(), "GS_READ_ONLY");
+
+ RuntimeException error = null;
+ try {
+ this.service.update(this.client, account);
+ } catch (RuntimeException e) {
+ error = e;
+ }
+
+ Assert.assertSame(failure, error);
+ Mockito.verify(this.auth).delSuperAdmin("alice");
+ Mockito.verify(this.auth).addSuperAdmin("alice");
+ ArgumentCaptor<User> updates = ArgumentCaptor.forClass(User.class);
+ Mockito.verify(this.auth, Mockito.times(2))
+ .updateUser(updates.capture());
+ Assert.assertSame(previous, updates.getAllValues().get(1));
}
@Test
@@ -312,6 +424,7 @@ public class UserServiceCompatibilityTest {
.thenReturn(user("user"));
Mockito.when(this.auth.listSuperAdmin())
.thenReturn(Collections.singletonList("user"));
+ Mockito.when(this.auth.getUser("user")).thenReturn(user("user"));
UserEntity account = UserEntity.builder()
.id("user")
.name("user")
@@ -326,7 +439,8 @@ public class UserServiceCompatibilityTest {
Mockito.verify(this.auth).delSuperAdmin("user");
Mockito.verify(this.graphSpaceUsers, Mockito.never())
.applySpacePreset(Mockito.any(), Mockito.anyString(),
- Mockito.anyString(), Mockito.anyString());
+ Mockito.anyString(), Mockito.anyString(),
+ Mockito.anyString());
}
@Test
@@ -391,6 +505,191 @@ public class UserServiceCompatibilityTest {
Mockito.any(), Mockito.any());
}
+ @Test
+ public void testModernScopedUserCreationRejectsEmptyGraphSpaces() {
+ Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
+ ReflectionTestUtils.setField(this.service, "graphSpaceUserService",
+ new GraphSpaceUserService());
+ UserEntity user = userEntity("display-name");
+ user.setPermissionPreset("GS_READ_WRITE");
+ user.setGraphspacePermissions(Collections.emptyList());
+
+ ParameterizedException error = null;
+ try {
+ this.service.add(this.client, user);
+ } catch (ParameterizedException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertEquals("auth.permission-preset.graphspace-required",
+ error.getMessage());
+ Mockito.verify(this.auth, Mockito.never())
+ .createUser(Mockito.any(User.class));
+ }
+
+ @Test
+ public void testModernScopedUserCreationRollsBackFailedPreset() {
+ Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
+ Mockito.when(this.auth.createUser(Mockito.any(User.class)))
+ .thenReturn(user("created-user"));
+ UserEntity user = userEntity("display-name");
+ user.setPermissionPreset("GS_READ_ONLY");
+ user.setGraphspacePermissions(Collections.singletonList(
+ permission("team", "GS_READ_ONLY")));
+ RuntimeException failure = new RuntimeException("preset failed");
+ Mockito.doThrow(failure).when(this.graphSpaceUsers)
+ .applyPermissionPresetsForNewAccount(
+ this.client, "user",
+ user.getGraphspacePermissions(),
+ "GS_READ_ONLY");
+
+ RuntimeException error = null;
+ try {
+ this.service.add(this.client, user);
+ } catch (RuntimeException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertSame(failure, error);
+ Mockito.verify(this.auth).deleteUser("created-user");
+ }
+
+ @Test
+ public void testSuperAdminGrantFailureDeletesNewAccount() {
+ Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
+ User created = user("alice");
+ created.setId("u-1");
+ Mockito.when(this.auth.createUser(Mockito.any(User.class)))
+ .thenReturn(created);
+ RuntimeException failure = new RuntimeException("grant failed");
+ Mockito.when(this.auth.addSuperAdmin("user")).thenThrow(failure);
+ UserEntity user = userEntity("display-name");
+ user.setPermissionPreset("SUPER_ADMIN");
+ user.setSuperadmin(true);
+
+ RuntimeException error = null;
+ try {
+ this.service.add(this.client, user);
+ } catch (RuntimeException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertSame(failure, error);
+ Mockito.verify(this.auth).addSuperAdmin("user");
+ Mockito.verify(this.auth).delSuperAdmin("user");
+ Mockito.verify(this.auth).deleteUser("u-1");
+ }
+
+ @Test
+ public void testLegacyAdminGrantFailureRollsBackEarlierSpaces() {
+ Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(this.client.supportsDefaultRole()).thenReturn(false);
+ User created = user("user");
+ created.setId("u-1");
+ Mockito.when(this.auth.createUser(Mockito.any(User.class)))
+ .thenReturn(created);
+ RuntimeException failure = new RuntimeException("grant failed");
+ Mockito.when(this.auth.addSpaceAdmin("user", "SECOND"))
+ .thenThrow(failure);
+ UserEntity user = userEntity("display-name");
+ user.setAdminSpaces(Arrays.asList("FIRST", "SECOND"));
+
+ RuntimeException error = null;
+ try {
+ this.service.add(this.client, user);
+ } catch (RuntimeException e) {
+ error = e;
+ }
+
+ Assert.assertSame(failure, error);
+ Mockito.verify(this.auth).delSpaceAdmin("user", "SECOND");
+ Mockito.verify(this.auth).delSpaceAdmin("user", "FIRST");
+ Mockito.verify(this.auth).deleteUser("u-1");
+ }
+
+ @Test
+ public void testModernUserCreationRejectsUnknownPreset() {
+ Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
+ ReflectionTestUtils.setField(this.service, "graphSpaceUserService",
+ new GraphSpaceUserService());
+ UserEntity user = userEntity("display-name");
+ user.setPermissionPreset("UNKNOWN");
+ user.setGraphspacePermissions(Collections.emptyList());
+
+ ParameterizedException error = null;
+ try {
+ this.service.add(this.client, user);
+ } catch (ParameterizedException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertEquals("auth.permission-preset.invalid",
+ error.getMessage());
+ Mockito.verify(this.auth, Mockito.never())
+ .createUser(Mockito.any(User.class));
+ }
+
+ @Test
+ public void testModernUserCreationRequiresPreset() {
+ Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
+ UserEntity user = userEntity("display-name");
+
+ ParameterizedException error = null;
+ try {
+ this.service.add(this.client, user);
+ } catch (ParameterizedException e) {
+ error = e;
+ }
+
+ Assert.assertNotNull(error);
+ Assert.assertEquals("auth.permission-preset.account-required",
+ error.getMessage());
+ Mockito.verify(this.auth, Mockito.never())
+ .createUser(Mockito.any(User.class));
+ }
+
+ @Test
+ public void testModernUserCreationRejectsSuperAdminMismatch() {
+ Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true);
+ Mockito.when(this.client.supportsDefaultRole()).thenReturn(true);
+ UserEntity superPreset = userEntity("display-name");
+ superPreset.setPermissionPreset("SUPER_ADMIN");
+
+ ParameterizedException missingFlag = null;
+ try {
+ this.service.add(this.client, superPreset);
+ } catch (ParameterizedException e) {
+ missingFlag = e;
+ }
+ Assert.assertNotNull(missingFlag);
+ Assert.assertEquals("auth.permission-preset.superadmin-mismatch",
+ missingFlag.getMessage());
+
+ UserEntity scopedPreset = userEntity("display-name");
+ scopedPreset.setPermissionPreset("GS_READ_ONLY");
+ scopedPreset.setSuperadmin(true);
+ ParameterizedException extraFlag = null;
+ try {
+ this.service.add(this.client, scopedPreset);
+ } catch (ParameterizedException e) {
+ extraFlag = e;
+ }
+ Assert.assertNotNull(extraFlag);
+ Assert.assertEquals("auth.permission-preset.superadmin-mismatch",
+ extraFlag.getMessage());
+ Mockito.verify(this.auth, Mockito.never())
+ .createUser(Mockito.any(User.class));
+ }
+
private static UserEntity userEntity(String nickname) {
return UserEntity.builder()
.name("user")
@@ -405,4 +704,12 @@ public class UserServiceCompatibilityTest {
user.name(name);
return user;
}
+
+ private static Map<String, String> permission(String graphSpace,
+ String preset) {
+ Map<String, String> permission = new HashMap<>();
+ permission.put("graphspace", graphSpace);
+ permission.put("permission_preset", preset);
+ return permission;
+ }
}
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 aaef6bb25..239ad6578 100644
--- a/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js
+++ b/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js
@@ -111,7 +111,7 @@ describe('auth API contract', () => {
'/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',
+ ['setSpacePreset', ['A/B', 'u/1', 'alice', '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'],
@@ -166,7 +166,11 @@ describe('auth API contract', () => {
}
else if (verb === 'put-preset') {
expect(mockRequest.put).toHaveBeenCalledWith(
- route, {permission_preset: args[2]}, config
+ route, {
+ user_id: args[1],
+ username: args[2],
+ permission_preset: args[3],
+ }, config
);
}
else {
diff --git a/hugegraph-hubble/hubble-fe/src/api/auth.js b/hugegraph-hubble/hubble-fe/src/api/auth.js
index 5b7075c89..6a7c97506 100644
--- a/hugegraph-hubble/hubble-fe/src/api/auth.js
+++ b/hugegraph-hubble/hubble-fe/src/api/auth.js
@@ -109,8 +109,10 @@ const removeSpaceAdmin = (graphspace, id, config) => {
return request.delete(scopedAuthPath(graphspace, 'users/spaceadmin', id), undefined, config);
};
-const setSpacePreset = (graphspace, id, preset, config) => {
+const setSpacePreset = (graphspace, id, username, preset, config) => {
return request.put(`${scopedAuthPath(graphspace, 'users', id)}/preset`, {
+ user_id: id === username ? undefined : id,
+ username,
permission_preset: preset,
}, config);
};
diff --git a/hugegraph-hubble/hubble-fe/src/api/request-error-semantics.test.js b/hugegraph-hubble/hubble-fe/src/api/request-error-semantics.test.js
index b539b7f42..febdbdc95 100644
--- a/hugegraph-hubble/hubble-fe/src/api/request-error-semantics.test.js
+++ b/hugegraph-hubble/hubble-fe/src/api/request-error-semantics.test.js
@@ -23,6 +23,7 @@ const loadResponseHandlers = modulePath => {
const messageError = jest.fn();
const modalWarning = jest.fn();
const clearLogin = jest.fn();
+ const isLogoutTransition = jest.fn(() => false);
const instance = {
interceptors: {
request: {
@@ -56,6 +57,7 @@ const loadResponseHandlers = modulePath => {
}));
jest.doMock('../utils/user', () => ({
clearLogin,
+ isLogoutTransition,
}));
const request = require(modulePath).default;
@@ -65,6 +67,7 @@ const loadResponseHandlers = modulePath => {
messageError,
modalWarning,
clearLogin,
+ isLogoutTransition,
instance,
request,
};
@@ -78,6 +81,7 @@ describe.each(['./request'])('%s error semantics', modulePath => {
search: '?x=1',
hash: '#result',
href: '',
+ replace: jest.fn(),
};
});
@@ -258,6 +262,49 @@ describe.each(['./request'])('%s error semantics', modulePath => {
expect(instance.delete).not.toHaveBeenCalled();
});
+ it('does not restore the previous route when logout receives business 401', async () => {
+ const {resolve, clearLogin, isLogoutTransition}
+ = loadResponseHandlers(modulePath);
+ isLogoutTransition.mockReturnValue(true);
+ const response = {
+ status: 200,
+ config: {url: '/auth/logout'},
+ data: {
+ status: 401,
+ message: 'Unauthorized',
+ },
+ };
+
+ await expect(resolve(response)).rejects.toBe(response);
+ expect(clearLogin).toHaveBeenCalledTimes(1);
+ expect(sessionStorage.getItem('redirect')).toBeNull();
+ expect(window.location.replace).toHaveBeenCalledWith('/login');
+ expect(window.location.href).toBe('');
+ });
+
+ it('does not restore the previous route when a concurrent status returns HTTP 401',
+ async () => {
+ const {reject, clearLogin, isLogoutTransition}
+ = loadResponseHandlers(modulePath);
+ isLogoutTransition.mockReturnValue(true);
+ const error = {
+ config: {url: '/auth/status'},
+ response: {
+ status: 401,
+ data: {
+ status: 401,
+ message: 'Unauthorized',
+ },
+ },
+ };
+
+ await expect(reject(error)).rejects.toBe(error);
+ expect(clearLogin).toHaveBeenCalledTimes(1);
+ expect(sessionStorage.getItem('redirect')).toBeNull();
+ expect(window.location.replace).toHaveBeenCalledWith('/login');
+ expect(window.location.href).toBe('');
+ });
+
it.each([401, 403])(
'shows business %s from login without redirecting',
async status => {
diff --git a/hugegraph-hubble/hubble-fe/src/api/request.js b/hugegraph-hubble/hubble-fe/src/api/request.js
index 3f3432e1d..1b63ae16e 100644
--- a/hugegraph-hubble/hubble-fe/src/api/request.js
+++ b/hugegraph-hubble/hubble-fe/src/api/request.js
@@ -40,7 +40,13 @@ const parseResponse = (data, headers) => {
};
const redirectToLogin = () => {
+ const logoutTransition = user.isLogoutTransition();
user.clearLogin();
+ if (logoutTransition) {
+ sessionStorage.removeItem('redirect');
+ window.location.replace('/login');
+ return;
+ }
if (window.location.pathname !== '/login') {
const redirect = `${window.location.pathname}${window.location.search}`
+ window.location.hash;
diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js
index e9f09b336..92ae8b41b 100644
--- a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js
+++ b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js
@@ -156,7 +156,17 @@ const GraphContextSwitcher = () => {
})
.catch(error => {
if (!cancelled) {
- setErrors(value => ({...value, graphs: errorKind(error)}));
+ const kind = errorKind(error);
+ if (pdEnabled && kind === 'forbidden') {
+ clearWorkbenchGraphContext(localStorage);
+ setContext({});
+ setGraphs([]);
+ setErrors(value => ({...value, graphs: false}));
+ navigate('/navigation', {replace: true});
+ }
+ else {
+ setErrors(value => ({...value, graphs: kind}));
+ }
setLoading(value => ({...value, graphs: false}));
}
});
@@ -164,7 +174,7 @@ const GraphContextSwitcher = () => {
return () => {
cancelled = true;
};
- }, [context.graphspace, navigate, reloadTokens.graphs]);
+ }, [context.graphspace, navigate, pdEnabled, reloadTokens.graphs]);
useEffect(() => {
if (!pdEnabled || loading.graphspaces || errors.graphspaces || !context.graphspace || (
@@ -173,18 +183,10 @@ const GraphContextSwitcher = () => {
return;
}
- const graphspace = graphspaces[0]?.name;
- const nextContext = graphspace ? {graphspace} : {};
- setContext(nextContext);
+ setContext({});
setGraphs([]);
- if (graphspace) {
- writeWorkbenchGraphContext(localStorage, nextContext);
- navigate(`/graphspace/${encodeURIComponent(graphspace)}`, {replace: true});
- }
- else {
- clearWorkbenchGraphContext(localStorage);
- navigate('/graphspace', {replace: true});
- }
+ clearWorkbenchGraphContext(localStorage);
+ navigate('/navigation', {replace: true});
}, [
context.graphspace,
errors.graphspaces,
diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js
index 70efc86ca..8e1d764df 100644
--- a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js
+++ b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.test.js
@@ -181,7 +181,7 @@ describe('GraphContextSwitcher', () => {
expect(screen.getByRole('option', {name: 'demo_space'})).toBeInTheDocument();
});
- test('replaces a missing GraphSpace and never reuses its graph', async () => {
+ test('clears a missing GraphSpace instead of guessing another one', async () => {
sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
api.manage.getGraphSpaceList.mockResolvedValueOnce({
status: 200,
@@ -191,14 +191,12 @@ describe('GraphContextSwitcher', () => {
await waitFor(() => {
expect(screen.getByRole('combobox', {name: 'workbench.context.graphspace'}))
- .toHaveValue('space_b');
+ .toHaveValue('');
});
expect(screen.getByRole('combobox', {name: 'workbench.context.graph'}))
.not.toHaveValue('old_graph');
- expect(screen.getByText('/graphspace/space_b')).toBeInTheDocument();
- expect(JSON.parse(localStorage.getItem('hubble_workbench_graph_context'))).toEqual({
- graphspace: 'space_b',
- });
+ expect(screen.getByText('/navigation')).toBeInTheDocument();
+ expect(localStorage.getItem('hubble_workbench_graph_context')).toBeNull();
});
test('clears a deep-linked graph that does not belong to its GraphSpace', async () => {
@@ -308,6 +306,26 @@ describe('GraphContextSwitcher', () => {
})).not.toBeInTheDocument();
});
+ test('clears a forbidden PD GraphSpace left by a previous account', async () => {
+ sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
+ localStorage.setItem('hubble_workbench_graph_context', JSON.stringify({
+ graphspace: 'space_a',
+ graph: 'graph_a',
+ }));
+ api.manage.getGraphList.mockResolvedValueOnce({status: 403, data: null});
+ renderSwitcher('/gremlin/space_a/graph_a');
+
+ await waitFor(() => {
+ expect(screen.getByText('/navigation')).toBeInTheDocument();
+ });
+ expect(localStorage.getItem('hubble_workbench_graph_context')).toBeNull();
+ expect(screen.getByRole('combobox', {
+ name: 'workbench.context.graphspace',
+ })).toHaveValue('');
+ expect(screen.queryByText('workbench.context.graphs_forbidden'))
+ .not.toBeInTheDocument();
+ });
+
test('graph success cannot erase a concurrent GraphSpace failure', async () => {
sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true}));
api.manage.getGraphSpaceList.mockResolvedValueOnce({status: 500, data: null});
diff --git a/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js b/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js
index 0f095e0bd..638f02974 100644
--- a/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js
+++ b/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js
@@ -34,8 +34,17 @@ import {
import {useAuthContext} from '../../auth/AuthContext';
import {isAuthEnabled} from '../../utils/config';
+const isUnauthorized = error => {
+ return error?.status === 401
+ || error?.data?.status === 401
+ || error?.response?.status === 401
+ || error?.response?.data?.status === 401
+ || error?.message?.includes('status code 401');
+};
+
const Topbar = () => {
const userInfo = user.getUser();
+ const userId = userInfo?.id;
const location = useLocation();
const {t} = useTranslation();
const {context: authContext} = useAuthContext();
@@ -51,13 +60,13 @@ const Topbar = () => {
useEffect(() => {
let cancelled = false;
- if (isAuthEnabled() && (!userInfo || !userInfo.id)) {
+ if (isAuthEnabled() && !userId) {
return undefined;
}
api.auth.status()
.then(res => {
- if (!cancelled && res.status === 401) {
+ if (!cancelled && !user.isLogoutTransition() && res.status === 401) {
redirectToLogin();
}
})
@@ -68,27 +77,37 @@ const Topbar = () => {
return () => {
cancelled = true;
};
- }, [redirectToLogin, userInfo]);
+ }, [redirectToLogin, userId]);
- if (isAuthEnabled() && (!userInfo || !userInfo.id)) {
- redirectToLogin();
- }
+ useEffect(() => {
+ if (isAuthEnabled() && !userId && !user.isLogoutTransition()) {
+ redirectToLogin();
+ }
+ }, [redirectToLogin, userId]);
const showShortcutHelp = useCallback(() => {
window.dispatchEvent(new CustomEvent('hubble:shortcut-help'));
}, []);
const logout = useCallback(() => {
-
- api.auth.logout().then(res => {
- if (res.status === 200) {
- sessionStorage.removeItem('redirect');
- clearPersistedAlgorithmFormsForUser();
- user.clearLogin();
- message.success(t('Topbar.exit.success'));
- window.location.replace('/login');
- }
- });
+ user.beginLogoutTransition();
+ api.auth.logout()
+ .then(res => {
+ if (res.status === 200) {
+ sessionStorage.removeItem('redirect');
+ clearPersistedAlgorithmFormsForUser();
+ user.clearLogin();
+ message.success(t('Topbar.exit.success'));
+ window.location.replace('/login');
+ return;
+ }
+ user.endLogoutTransition();
+ })
+ .catch(error => {
+ if (!isUnauthorized(error)) {
+ user.endLogoutTransition();
+ }
+ });
}, [t]);
const userMenu = {
diff --git a/hugegraph-hubble/hubble-fe/src/components/Topbar/topbar-request-error.test.js b/hugegraph-hubble/hubble-fe/src/components/Topbar/topbar-request-error.test.js
index 9430f7250..dfdbc4aaf 100644
--- a/hugegraph-hubble/hubble-fe/src/components/Topbar/topbar-request-error.test.js
+++ b/hugegraph-hubble/hubble-fe/src/components/Topbar/topbar-request-error.test.js
@@ -20,6 +20,7 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import Topbar from './index.ant';
import * as api from '../../api/index';
+import * as user from '../../utils/user';
const mockUseAuthContext = jest.fn();
@@ -116,6 +117,7 @@ jest.mock('antd', () => {
describe('Topbar request errors', () => {
beforeEach(() => {
jest.clearAllMocks();
+ user.endLogoutTransition();
api.manage.getGraphList.mockResolvedValue({
status: 200,
data: {records: [{name: 'hugegraph'}]},
@@ -276,9 +278,9 @@ describe('Topbar request errors', () => {
api.auth.status.mockResolvedValue({status: 200});
api.auth.logout.mockResolvedValue({status: 200});
- render(
+ const view = render(
<MemoryRouter
- initialEntries={['/navigation']}
+ initialEntries={['/graphspace/DEFAULT']}
future={{v7_startTransition: true, v7_relativeSplatPath: true}}
>
<Topbar />
@@ -289,5 +291,48 @@ describe('Topbar request errors', () => {
await waitFor(() => expect(api.auth.logout).toHaveBeenCalledTimes(1));
expect(window.location.replace).toHaveBeenCalledWith('/login');
+ view.rerender(
+ <MemoryRouter
+ initialEntries={['/graphspace/DEFAULT']}
+ future={{v7_startTransition: true, v7_relativeSplatPath: true}}
+ >
+ <Topbar />
+ </MemoryRouter>
+ );
+ expect(sessionStorage.getItem('redirect')).toBeNull();
+ expect(window.location.href).toBe('http://localhost/navigation');
+ expect(user.isLogoutTransition()).toBe(true);
+ });
+
+ it.each([
+ ['HTTP', {response: {status: 401, data: {status: 401}}}],
+ ['business', {status: 200, data: {status: 401}}],
+ ])('does not restore the old route after a logout %s 401', async (_, error) => {
+ api.auth.status.mockResolvedValue({status: 200});
+ api.auth.logout.mockRejectedValue(error);
+
+ const view = render(
+ <MemoryRouter
+ initialEntries={['/graphspace/DEFAULT']}
+ future={{v7_startTransition: true, v7_relativeSplatPath: true}}
+ >
+ <Topbar />
+ </MemoryRouter>
+ );
+
+ fireEvent.click(screen.getByRole('button', {name: 'Topbar.exit.name'}));
+ await waitFor(() => expect(api.auth.logout).toHaveBeenCalledTimes(1));
+ view.rerender(
+ <MemoryRouter
+ initialEntries={['/graphspace/DEFAULT']}
+ future={{v7_startTransition: true, v7_relativeSplatPath: true}}
+ >
+ <Topbar />
+ </MemoryRouter>
+ );
+
+ expect(user.isLogoutTransition()).toBe(true);
+ expect(sessionStorage.getItem('redirect')).toBeNull();
+ expect(window.location.href).toBe('http://localhost/navigation');
});
});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js
index 8e25be843..df60813f8 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js
@@ -32,8 +32,8 @@ import {
PERMISSION_PRESETS,
toPermissionPayload,
} from './permissionPresets';
+import {loadAllPages, PAGE_ERROR_CONFIG} from './pagedRecords';
-const PAGE_ERROR_CONFIG = {suppressBusinessErrorToast: true};
const DEFAULT_ALLOWED_OPERATIONS = {create: true, edit: true, auth: true};
const PRESERVE_PERMISSIONS = 'PRESERVE_PERMISSIONS';
const permissionPresetChanged = (prev, next) => prev.permission_preset !== next.permission_preset;
@@ -44,6 +44,8 @@ const toProfilePayload = values => ({
user_description: values.user_description,
});
+const loadAllGraphspaces = () => loadAllPages(api.manage.getGraphSpaceList);
+
const HelpLabel = ({t, labelKey}) => (
<FormHelpLabel
label={t(labelKey)}
@@ -189,7 +191,7 @@ const EditLayer = ({
detailRequest.current = request;
setGraphspaceList([]);
if (op !== 'detail' && (permissionPresetsSupported || op === 'auth')) {
- api.manage.getGraphSpaceList(undefined, PAGE_ERROR_CONFIG).then(res => {
+ loadAllGraphspaces().then(res => {
if (detailRequest.current !== request) {
return;
}
@@ -426,6 +428,7 @@ const EditLayer = ({
/>
)}
name="graphspaces"
+ rules={[rules.required()]}
>
<Select options={graphspaceList} mode="multiple" />
</Form.Item>
@@ -452,6 +455,7 @@ const EditLayer = ({
<Form.Item
label={<HelpLabel t={t} labelKey='account.form.graphspaces' />}
name="graphspaces"
+ rules={[rules.required()]}
>
<Select options={graphspaceList} mode="multiple" />
</Form.Item>
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js
index 8dd3fa1b0..a1c53f90f 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js
@@ -36,9 +36,8 @@ import * as api from '../../api';
import TableHeader from '../../components/TableHeader';
import {useAuthContext} from '../../auth/AuthContext';
import {PERMISSION_PRESETS} from './permissionPresets';
+import {loadAllPages, PAGE_ERROR_CONFIG} from './pagedRecords';
-const PAGE_ERROR_CONFIG = {suppressBusinessErrorToast: true};
-const PAGE_PARAMS = {query: '', page_no: 1, page_size: 200};
const responseRecords = response => response?.data?.records ?? [];
const showMutationError = (error, t) => {
const response = error?.response ?? error;
@@ -205,7 +204,7 @@ const SpaceAccess = () => {
spacesRequest.current = token;
setSpacesLoading(true);
setSpacesError(false);
- api.manage.getGraphSpaceList(PAGE_PARAMS, PAGE_ERROR_CONFIG)
+ loadAllPages(api.manage.getGraphSpaceList, {params: {query: ''}})
.then(response => {
if (spacesRequest.current !== token) {
return;
@@ -235,11 +234,13 @@ const SpaceAccess = () => {
const spaces = scopes.all_graphspaces ? allSpaces : scopedSpaces;
const graphSpace = spaces.includes(selectedSpace) ? selectedSpace : spaces[0];
- const loadMembers = useCallback(space => api.auth.getSpaceMembers(
- space, PAGE_PARAMS, PAGE_ERROR_CONFIG
+ const loadMembers = useCallback(space => loadAllPages(
+ (params, config) => api.auth.getSpaceMembers(space, params, config),
+ {params: {query: ''}}
), []);
- const loadAdmins = useCallback(space => api.auth.getSpaceAdmins(
- space, PAGE_PARAMS, PAGE_ERROR_CONFIG
+ const loadAdmins = useCallback(space => loadAllPages(
+ (params, config) => api.auth.getSpaceAdmins(space, params, config),
+ {params: {query: ''}}
), []);
const members = useScopedResource(
graphSpace, contextVersion, loadMembers, responseRecords
@@ -287,6 +288,7 @@ const SpaceAccess = () => {
const openMember = useCallback(row => {
memberForm.setFieldsValue({
user_id: row?.user_id,
+ username: row?.user_name,
permission_preset: rolesPreset(row?.roles),
});
setMemberDialog(row ?? {});
@@ -297,7 +299,11 @@ const SpaceAccess = () => {
}, [memberForm]);
const submitMember = useCallback(values => {
runMutation(
- () => api.auth.setSpacePreset(graphSpace, values.user_id, values.permission_preset,
+ () => api.auth.setSpacePreset(
+ graphSpace,
+ values.user_id ?? values.username,
+ values.username,
+ values.permission_preset,
PAGE_ERROR_CONFIG
),
closeMember
@@ -431,13 +437,28 @@ const SpaceAccess = () => {
destroyOnClose
>
<Form form={memberForm} layout="vertical" onFinish={submitMember}>
- <Form.Item
- name="user_id"
- label={t('account.space_access.member.id')}
- rules={[{required: true}]}
- >
- <Input disabled={Boolean(memberDialog?.user_id)} />
- </Form.Item>
+ {memberDialog?.user_id ? (
+ <>
+ <Form.Item
+ name="user_id"
+ label={t('account.space_access.member.id')}
+ rules={[{required: true}]}
+ >
+ <Input disabled />
+ </Form.Item>
+ <Form.Item name="username" hidden>
+ <Input />
+ </Form.Item>
+ </>
+ ) : (
+ <Form.Item
+ name="username"
+ label={t('account.space_access.member.name')}
+ rules={[{required: true}]}
+ >
+ <Input />
+ </Form.Item>
+ )}
<Form.Item
name="permission_preset"
label={t('account.space_access.member.roles')}
@@ -461,5 +482,5 @@ const SpaceAccess = () => {
);
};
-export {rolesPreset};
+export {loadAllPages, 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 bcbcade6c..2d72aaa7b 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.test.js
@@ -24,7 +24,7 @@ import {
waitFor,
within,
} from '@testing-library/react';
-import SpaceAccess, {rolesPreset} from './SpaceAccess';
+import SpaceAccess, {loadAllPages, rolesPreset} from './SpaceAccess';
import * as api from '../../api';
let mockAuthContext;
@@ -115,6 +115,90 @@ test('keeps graphspace administrator selected with member roles', () => {
])).toBeNull();
});
+test('loads every page without silently truncating member-management data', async () => {
+ const firstPage = Array.from({length: 500}, (_, index) => ({id: index}));
+ const request = jest.fn()
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: firstPage, total: '501'},
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: [{id: 500}], total: 501},
+ });
+
+ const response = await loadAllPages(request, {params: {query: ''}});
+
+ expect(response.data.records).toHaveLength(501);
+ expect(request).toHaveBeenNthCalledWith(1, {
+ query: '',
+ page_no: 1,
+ page_size: 500,
+ }, expect.any(Object));
+ expect(request).toHaveBeenNthCalledWith(2, {
+ query: '',
+ page_no: 2,
+ page_size: 500,
+ }, expect.any(Object));
+});
+
+test('continues after short pages while declared records remain', async () => {
+ const page = offset => Array.from({length: 200}, (_, index) => ({
+ id: offset + index,
+ }));
+ const request = jest.fn()
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: page(0), total: 501},
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: page(200), total: 501},
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: page(400).slice(0, 101), total: 501},
+ });
+
+ const response = await loadAllPages(request);
+
+ expect(response.data.records).toHaveLength(501);
+ expect(request).toHaveBeenCalledTimes(3);
+});
+
+test('treats blank total as absent instead of zero', async () => {
+ const firstPage = Array.from({length: 500}, (_, index) => ({id: index}));
+ const request = jest.fn()
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: firstPage, total: ' '},
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: [{id: 500}], total: ' '},
+ });
+
+ const response = await loadAllPages(request);
+
+ expect(response.data.records).toHaveLength(501);
+ expect(request).toHaveBeenCalledTimes(2);
+});
+
+test('fails when a paged response ends before its declared total', async () => {
+ const request = jest.fn()
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: [{id: 0}], total: 2},
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: [], total: 2},
+ });
+
+ await expect(loadAllPages(request))
+ .rejects.toThrow('Record list ended before its declared total');
+});
+
test('shows legacy role names without treating them as presets', async () => {
setResponses({
members: [{
@@ -248,8 +332,7 @@ test('submits only the selected preset when adding a member', async () => {
fireEvent.click(screen.getByRole('button', {name: 'OK'}));
await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledWith(
- 'SPACE_A', 'bob', 'GS_READ_WRITE', expect.any(Object)
- ));
+ 'SPACE_A', 'bob', 'bob', 'GS_READ_WRITE', expect.any(Object)));
});
test('uses the preset API for GS admin', async () => {
@@ -277,8 +360,7 @@ test('uses the preset API for GS admin', async () => {
fireEvent.click(screen.getByRole('button', {name: 'OK'}));
await waitFor(() => expect(api.auth.setSpacePreset).toHaveBeenCalledWith(
- 'SPACE_A', 'bob', 'GS_ADMIN', expect.any(Object)
- ));
+ 'SPACE_A', 'bob', 'bob', 'GS_ADMIN', expect.any(Object)));
expect(api.auth.addSpaceMember).not.toHaveBeenCalled();
});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js
index 4521c33c5..14c5e20f2 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js
@@ -213,6 +213,10 @@ test('preserves mixed GraphSpace permissions on profile edit', async () => {
test('ignores a second account mutation while the first submit is pending', async () => {
const detailRequest = deferred();
const mutation = deferred();
+ api.manage.getGraphSpaceList.mockResolvedValue({
+ status: 200,
+ data: {records: [{name: 'SPACE'}]},
+ });
api.auth.getUserInfo.mockReturnValue(detailRequest.promise);
api.auth.updateAdminspace.mockReturnValue(mutation.promise);
render(<EditLayer {...props} data={{id: 'A'}} op='auth' />);
@@ -223,7 +227,7 @@ test('ignores a second account mutation while the first submit is pending', asyn
await act(async () => detailRequest.resolve({
status: 200,
- data: {user_name: 'alice', adminSpaces: []},
+ data: {user_name: 'alice', adminSpaces: ['SPACE']},
}));
fireEvent.click(submit);
fireEvent.click(submit);
@@ -281,11 +285,100 @@ test('loads graphspaces into the visible create account form', async () => {
status: 200,
data: {records: [{name: 'analytics'}]},
}));
+ expect(api.manage.getGraphSpaceList).toHaveBeenCalledWith({
+ page_no: 1,
+ page_size: 500,
+ }, {suppressBusinessErrorToast: true});
fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
expect(await screen.findByRole('option', {name: 'analytics'})).toBeInTheDocument();
});
+test('loads every GraphSpace page for permission assignment', async () => {
+ api.manage.getGraphSpaceList
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: [{name: 'first'}], total: '2'},
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: [{name: 'second'}], total: 2},
+ });
+
+ render(<EditLayer {...props} data={{}} op='create' />);
+ await waitFor(() =>
+ expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(2)
+ );
+ fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
+
+ expect(await screen.findByRole('option', {name: 'first'}))
+ .toBeInTheDocument();
+ expect(await screen.findByRole('option', {name: 'second'}))
+ .toBeInTheDocument();
+});
+
+test('continues after a full GraphSpace page without total', async () => {
+ const firstPage = Array.from({length: 500}, (_, index) => ({
+ name: `space-${index}`,
+ }));
+ api.manage.getGraphSpaceList
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: firstPage},
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: [{name: 'last'}]},
+ });
+
+ render(<EditLayer {...props} data={{}} op='create' />);
+
+ await waitFor(() =>
+ expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(2)
+ );
+});
+
+test.each([null, ''])(
+ 'continues after a full GraphSpace page with empty total %p',
+ async total => {
+ const firstPage = Array.from({length: 500}, (_, index) => ({
+ name: `space-${index}`,
+ }));
+ api.manage.getGraphSpaceList
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: firstPage, total},
+ })
+ .mockResolvedValueOnce({
+ status: 200,
+ data: {records: [{name: 'last'}]},
+ });
+
+ render(<EditLayer {...props} data={{}} op='create' />);
+
+ await waitFor(() =>
+ expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(2)
+ );
+ }
+);
+
+test('accepts exactly 10000 GraphSpaces without a declared total', async () => {
+ api.manage.getGraphSpaceList.mockImplementation(({page_no: pageNo}) => {
+ const records = pageNo <= 20
+ ? Array.from({length: 500}, (_, index) => ({
+ name: `space-${pageNo}-${index}`,
+ }))
+ : [];
+ return Promise.resolve({status: 200, data: {records}});
+ });
+
+ render(<EditLayer {...props} data={{}} op='create' />);
+
+ await waitFor(() =>
+ expect(api.manage.getGraphSpaceList).toHaveBeenCalledTimes(21)
+ );
+});
+
test('requires an explicit password when creating an account', async () => {
render(<EditLayer {...props} data={{}} op='create' />);
await act(async () => undefined);
@@ -295,6 +388,49 @@ test('requires an explicit password when creating an account', async () => {
)).toBeRequired();
});
+test('requires a GraphSpace when creating a scoped account', async () => {
+ api.auth.addUser.mockResolvedValue({status: 200});
+ render(<EditLayer {...props} data={{}} op='create' />);
+ await act(async () => undefined);
+
+ fireEvent.change(screen.getByPlaceholderText(
+ 'account.form.id_placeholder'
+ ), {target: {value: 'alice'}});
+ fireEvent.change(screen.getByPlaceholderText(
+ 'account.form.default_password_placeholder'
+ ), {target: {value: 'secret'}});
+ fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
+ fireEvent.click(screen.getByText(
+ 'account.permission_preset.GS_READ_WRITE'
+ ));
+ fireEvent.click(document.querySelector(
+ '.ant-modal-footer .ant-btn-primary'
+ ));
+
+ await waitFor(() =>
+ expect(document.querySelector('.ant-form-item-has-error'))
+ .not.toBeNull()
+ );
+ expect(api.auth.addUser).not.toHaveBeenCalled();
+});
+
+test('requires a GraphSpace when granting space administration', async () => {
+ api.auth.updateAdminspace.mockResolvedValue({status: 200});
+ render(<EditLayer {...props} data={{id: 'A'}} op='auth' />);
+ const submit = document.querySelector(
+ '.ant-modal-footer .ant-btn-primary'
+ );
+ await waitFor(() => expect(submit).not.toBeDisabled());
+
+ fireEvent.click(submit);
+
+ await waitFor(() =>
+ expect(document.querySelector('.ant-form-item-has-error'))
+ .not.toBeNull()
+ );
+ expect(api.auth.updateAdminspace).not.toHaveBeenCalled();
+});
+
test('keeps a specific account creation error visible in the form', async () => {
api.auth.addUser.mockResolvedValue({
status: 400,
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/pagedRecords.js b/hugegraph-hubble/hubble-fe/src/pages/Account/pagedRecords.js
new file mode 100644
index 000000000..627e15f3b
--- /dev/null
+++ b/hugegraph-hubble/hubble-fe/src/pages/Account/pagedRecords.js
@@ -0,0 +1,74 @@
+/*
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const PAGE_ERROR_CONFIG = {suppressBusinessErrorToast: true};
+const PAGE_SIZE = 500;
+const MAX_RECORDS = 10_000;
+
+const loadAllPages = async (request, {params = {}, config = PAGE_ERROR_CONFIG} = {}) => {
+ const records = [];
+ let pageNo = 1;
+ let total;
+
+ while (records.length <= MAX_RECORDS) {
+ const response = await request({
+ ...params,
+ page_no: pageNo,
+ page_size: PAGE_SIZE,
+ }, config);
+ if (response?.status !== 200) {
+ return response;
+ }
+ const pageRecords = response?.data?.records ?? [];
+ if (records.length + pageRecords.length > MAX_RECORDS) {
+ throw new Error(`Record list exceeds ${MAX_RECORDS}`);
+ }
+ records.push(...pageRecords);
+
+ const rawTotal = response.data?.total;
+ const normalizedTotal = typeof rawTotal === 'string'
+ ? rawTotal.trim()
+ : rawTotal;
+ if (normalizedTotal !== undefined
+ && normalizedTotal !== null
+ && normalizedTotal !== '') {
+ const declaredTotal = Number(normalizedTotal);
+ if (Number.isFinite(declaredTotal) && declaredTotal >= 0) {
+ if (declaredTotal > MAX_RECORDS) {
+ throw new Error(`Record list exceeds ${MAX_RECORDS}`);
+ }
+ total = declaredTotal;
+ }
+ }
+ if (total !== undefined && records.length < total
+ && pageRecords.length === 0) {
+ throw new Error('Record list ended before its declared total');
+ }
+ if ((total !== undefined && records.length >= total)
+ || (total === undefined && pageRecords.length < PAGE_SIZE)) {
+ return {
+ status: 200,
+ data: {records, total: total ?? records.length},
+ };
+ }
+ pageNo += 1;
+ }
+ throw new Error(`Record list exceeds ${MAX_RECORDS}`);
+};
+
+export {loadAllPages, PAGE_ERROR_CONFIG};
diff --git a/hugegraph-hubble/hubble-fe/src/pages/My/index.js b/hugegraph-hubble/hubble-fe/src/pages/My/index.js
index e3ac4896a..cad01a205 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/My/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/My/index.js
@@ -24,9 +24,11 @@ import style from './index.module.scss';
import EditLayer from './EditLayer';
import * as api from '../../api';
import * as rules from '../../utils/rules';
+import {useAuthContext} from '../../auth/AuthContext';
const My = () => {
const {t} = useTranslation();
+ const {context} = useAuthContext();
const [editLayerVisible, setEditLayerVisible] = useState(false);
const [changePass, setChangePass] = useState(false);
const [refresh, setRefresh] = useState(false);
@@ -63,6 +65,9 @@ const My = () => {
: (Array.isArray(data.adminSpaces) && data.adminSpaces.length > 0
? data.adminSpaces.join(', ')
: '');
+ const accountActions = context?.actions?.account ?? [];
+ const canUpdateProfile = accountActions.includes('update');
+ const canChangePassword = accountActions.includes('change_password');
const handleForm = useCallback(async () => {
try {
@@ -197,20 +202,24 @@ const My = () => {
</div>
{!changePass && (
<Space className={style.profileActions} wrap>
- <Button
- icon={<EditOutlined aria-hidden='true' />}
- onClick={handleShowLayer}
- disabled={spinning || profileError}
- >
- {t('common.action.edit')}
- </Button>
- <Button
- icon={<LockOutlined aria-hidden='true' />}
- onClick={handleChange}
- disabled={spinning || profileError}
- >
- {t('my.edit.title')}
- </Button>
+ {canUpdateProfile && (
+ <Button
+ icon={<EditOutlined aria-hidden='true' />}
+ onClick={handleShowLayer}
+ disabled={spinning || profileError}
+ >
+ {t('common.action.edit')}
+ </Button>
+ )}
+ {canChangePassword && (
+ <Button
+ icon={<LockOutlined aria-hidden='true' />}
+ onClick={handleChange}
+ disabled={spinning || profileError}
+ >
+ {t('my.edit.title')}
+ </Button>
+ )}
</Space>
)}
</header>
diff --git a/hugegraph-hubble/hubble-fe/src/pages/My/my-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/My/my-recovery.test.js
index 25999455f..a530bbe62 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/My/my-recovery.test.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/My/my-recovery.test.js
@@ -21,10 +21,16 @@ import userEvent from '@testing-library/user-event';
import My from './index';
import * as api from '../../api';
+let mockAuthContext = null;
+
jest.mock('react-i18next', () => ({
useTranslation: () => ({t: key => key}),
}));
+jest.mock('../../auth/AuthContext', () => ({
+ useAuthContext: () => ({context: mockAuthContext}),
+}));
+
jest.mock('../../api', () => ({
auth: {
getPersonal: jest.fn(),
@@ -43,6 +49,11 @@ jest.mock('../../utils/rules', () => ({
beforeEach(() => {
jest.clearAllMocks();
+ mockAuthContext = {
+ actions: {
+ account: ['read', 'update', 'change_password'],
+ },
+ };
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: false,
media: query,
@@ -52,6 +63,25 @@ beforeEach(() => {
api.auth.status.mockResolvedValue({status: 200, data: {level: 'ADMIN'}});
});
+test('keeps legacy profile read and password actions without edit', async () => {
+ mockAuthContext = {
+ actions: {account: ['read', 'change_password']},
+ };
+ api.auth.getPersonal.mockResolvedValue({
+ status: 200,
+ data: {user_name: 'alice'},
+ });
+
+ render(<My />);
+
+ expect(await screen.findByRole('heading', {name: 'alice'}))
+ .toBeInTheDocument();
+ expect(screen.queryByRole('button', {name: 'common.action.edit'}))
+ .not.toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'my.edit.title'}))
+ .toBeInTheDocument();
+});
+
const deferred = () => {
let resolve;
const promise = new Promise(resolvePromise => {
diff --git a/hugegraph-hubble/hubble-fe/src/utils/user.js b/hugegraph-hubble/hubble-fe/src/utils/user.js
index 3967784c3..ec5e10bc7 100644
--- a/hugegraph-hubble/hubble-fe/src/utils/user.js
+++ b/hugegraph-hubble/hubble-fe/src/utils/user.js
@@ -18,6 +18,7 @@
const key = 'user_'; // 防止缓存问题
const USER_CHANGE_EVENT = 'hubble:user-change';
+let logoutTransition = false;
const notifyUserChange = () => {
window.dispatchEvent(new CustomEvent(USER_CHANGE_EVENT));
@@ -56,6 +57,16 @@ const clearLogin = () => {
clearUser();
};
+const beginLogoutTransition = () => {
+ logoutTransition = true;
+};
+
+const endLogoutTransition = () => {
+ logoutTransition = false;
+};
+
+const isLogoutTransition = () => logoutTransition;
+
const getDefaultGraphspace = () => {
const user = getUser();
@@ -80,6 +91,9 @@ export {
scopedStorageKey,
clearUser,
clearLogin,
+ beginLogoutTransition,
+ endLogoutTransition,
+ isLogoutTransition,
getDefaultGraphspace,
USER_CHANGE_EVENT,
};