diff --git a/.editorconfig b/.editorconfig index cf79877b0..4a6e05ecb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,9 +21,9 @@ root = true charset = utf-8 end_of_line = lf insert_final_newline = true -max_line_length = 100 +max_line_length = 120 ij_wrap_on_typing = true -ij_visual_guides = 100 +ij_visual_guides = 120 [*.{java,xml,py}] diff --git a/.github/actions/upload-coverage/action.yml b/.github/actions/upload-coverage/action.yml index 143da955b..bf8ba8cd8 100644 --- a/.github/actions/upload-coverage/action.yml +++ b/.github/actions/upload-coverage/action.yml @@ -17,4 +17,5 @@ runs: uses: codecov/codecov-action@v7 with: token: ${{ inputs.token }} + slug: ${{ github.repository }} files: ${{ inputs.file }} diff --git a/.github/workflows/client-ci.yml b/.github/workflows/client-ci.yml index e076dd256..90d7e8110 100644 --- a/.github/workflows/client-ci.yml +++ b/.github/workflows/client-ci.yml @@ -68,4 +68,4 @@ jobs: uses: ./.github/actions/upload-coverage with: token: ${{ secrets.CODECOV_TOKEN }} - file: target/jacoco.xml + file: hugegraph-client/target/jacoco.xml diff --git a/.github/workflows/hubble-ci.yml b/.github/workflows/hubble-ci.yml index 5445dbb0b..ae7290aa4 100644 --- a/.github/workflows/hubble-ci.yml +++ b/.github/workflows/hubble-ci.yml @@ -23,8 +23,9 @@ on: env: TRAVIS_DIR: hugegraph-hubble/hubble-dist/assembly/travis - HUGEGRAPH_SERVER_COMMIT: 99936be5f41fccd193f120e01206e3cf3c73a050 - HUGEGRAPH_SERVER_FETCH_REF: refs/heads/master + # Server PR #3159 declares the GraphSpace default-role contract as API 0.72. + HUGEGRAPH_SERVER_COMMIT: 52035dad9ee8d6b666329ca0d03950c773d3e1eb + HUGEGRAPH_SERVER_FETCH_REF: refs/pull/3159/head jobs: hubble-ci: @@ -138,6 +139,16 @@ jobs: cd ../../../ pwd $TRAVIS_DIR/install-hugegraph.sh $COMMIT_ID $COMMIT_REF + API_VERSION="$(curl --fail --silent http://127.0.0.1:8080/versions | + python -c 'import json,sys; print(json.load(sys.stdin)["versions"]["api"])')" + python - "$API_VERSION" <<'PY' + import sys + parts = tuple(int(value) for value in sys.argv[1].split(".")[:2]) + if parts < (0, 72): + raise SystemExit( + f"Expected HugeGraph REST API >= 0.72, got {sys.argv[1]}" + ) + PY - name: Release package audit env: @@ -197,4 +208,4 @@ jobs: uses: ./.github/actions/upload-coverage with: token: ${{ secrets.CODECOV_TOKEN }} - file: target/site/jacoco/*.xml + file: hugegraph-hubble/hubble-be/target/jacoco.xml diff --git a/.github/workflows/loader-ci.yml b/.github/workflows/loader-ci.yml index f8eee02d3..8389ecab6 100644 --- a/.github/workflows/loader-ci.yml +++ b/.github/workflows/loader-ci.yml @@ -94,4 +94,4 @@ jobs: uses: ./.github/actions/upload-coverage with: token: ${{ secrets.CODECOV_TOKEN }} - file: target/jacoco.xml + file: hugegraph-loader/target/jacoco.xml diff --git a/.github/workflows/tools-ci.yml b/.github/workflows/tools-ci.yml index 340f71aa1..4613bcd0f 100644 --- a/.github/workflows/tools-ci.yml +++ b/.github/workflows/tools-ci.yml @@ -59,9 +59,3 @@ jobs: - name: Run test run: | mvn test -Dtest=FuncTestSuite -pl hugegraph-tools -ntp - - - name: Upload coverage to Codecov - uses: ./.github/actions/upload-coverage - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: target/jacoco.xml diff --git a/.serena/memories/README_INDEX.md b/.serena/memories/README_INDEX.md index 3f506906f..4f5dd6806 100644 --- a/.serena/memories/README_INDEX.md +++ b/.serena/memories/README_INDEX.md @@ -212,7 +212,7 @@ git --no-pager diff HEAD~1 4. ❌ **DON'T** use `System.out.println` (use logger instead) 5. ❌ **DON'T** forget Apache 2.0 license headers 6. ❌ **DON'T** use tabs (use 4 spaces for Java, 2 for frontend) -7. ❌ **DON'T** exceed 100 character line length +7. ❌ **DON'T** exceed 120 character line length 8. ❌ **DON'T** commit code that fails CI checks ## Getting Help diff --git a/.serena/memories/code_style_and_conventions.md b/.serena/memories/code_style_and_conventions.md index 0c14759c6..594063a5f 100644 --- a/.serena/memories/code_style_and_conventions.md +++ b/.serena/memories/code_style_and_conventions.md @@ -12,7 +12,7 @@ ### Basic Formatting - **Indentation**: 4 spaces (NO TABS) - **Continuation Indent**: 8 spaces -- **Line Length**: Maximum 100 characters +- **Line Length**: Maximum 120 characters - **Line Wrapping**: Enabled for long lines - **Blank Lines**: - Keep max 1 blank line in declarations diff --git a/AGENTS.md b/AGENTS.md index 4aa6cceaa..9f8ca9eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ mvn test -Dtest=FuncTestSuite -pl hugegraph-tools -ntp ## Code Style Checkstyle enforced via `tools/checkstyle.xml`: -- Max line length: 100 characters +- Max line length: 120 characters - 4-space indentation (no tabs) - No star imports - No `System.out.println` diff --git a/README.md b/README.md index 02c1bb719..bdc68a7cd 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ A comprehensive suite of client SDKs, data tools, and management utilities for [Apache HugeGraph](https://github.com/apache/hugegraph) graph database. Build applications, load data, and manage graphs with production-ready tools. +Hubble's primary authentication and connection design targets HugeGraph +`1.8/master`: PD discovery supplies the server address, anonymous mode uses a +real unauthenticated client, and account/GraphSpace permissions are reduced to +four readable presets. A thin adapter keeps 1.7 usable and limits 1.5 to its +standalone core graph workflow; version checks are centralized rather than +spread across UI pages. + **Quick Navigation**: [Architecture](#architecture-overview) | [Quick Start](#quick-start) | [Modules](#module-overview) | [Build](#build--development) | [Docker](#docker) | [Related Projects](#related-projects) ## Related Projects @@ -57,7 +64,7 @@ graph TB CLIENT --> HUBBLE CLIENT --> TOOLS CLIENT --> SPARK - HUBBLE -.->|WIP: pd-client| PD + HUBBLE -.->|PD discovery UI| PD LOADER -.->|Sources| SRC["CSV | JSON | HDFS
MySQL | Kafka"] SPARK -.->|I/O| SPK["Spark DataFrames"] @@ -381,7 +388,7 @@ mvn clean install -DskipTests -Dmaven.javadoc.skip=true -ntp ### Code Style Checkstyle is enforced via `tools/checkstyle.xml`: -- Max line length: 100 characters +- Max line length: 120 characters - 4-space indentation (no tabs) - No star imports - No `System.out.println` diff --git a/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 boolean isSpaceAdmin(String graphSpace) { .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 3f26091f3..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,8 +22,12 @@ 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; @@ -57,6 +61,8 @@ public class HugeClient implements Closeable { */ private volatile boolean apiVersionChecked; private final Object apiVersionLock = new Object(); + private ServerCompatibility.Profile compatibility = + ServerCompatibility.Profile.LEGACY; private VersionManager version; private GraphsManager graphs; private SchemaManager schema; @@ -209,8 +215,9 @@ private void checkServerApiVersion() { // 0.81 equals to the {latest_api_version} +10 VersionUtil.check(apiVersion, "0.38", "0.81", "hugegraph-api in server"); this.client.apiVersion(apiVersion); - boolean supportGs = VersionUtil.gte(this.version.getCoreVersion(), "1.7.0"); - this.client.setSupportGs(supportGs); + this.compatibility = ServerCompatibility.profile( + this.version.getCoreVersion(), apiVersion.get()); + this.client.setSupportGs(this.compatibility.supportsGraphSpace()); } public GraphsManager graphs() { @@ -257,6 +264,50 @@ public AuthManager auth() { return this.auth; } + public boolean supportsDefaultRole() { + return this.compatibility.supportsDefaultRole(); + } + + public boolean supportsPersonalProfileUpdate() { + return this.compatibility.supportsPersonalProfileUpdate(); + } + + public User findUserByName(String name) { + if (this.supportsDefaultRole()) { + return this.auth.getUserByName(name); + } + return this.auth.listUsers().stream() + .filter(user -> name.equals(user.name())) + .findFirst() + .orElse(null); + } + + public 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 new file mode 100644 index 000000000..ac329aacf --- /dev/null +++ b/hugegraph-client/src/main/java/org/apache/hugegraph/driver/ServerCompatibility.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.driver; + +import org.apache.hugegraph.util.VersionUtil; + +/** + * Small compatibility boundary shared by Hubble and clients. + * + *

Version checks belong here so callers can express capabilities instead + * of branching on server versions in controllers or pages. Unknown versions + * deliberately use the conservative legacy profile.

+ */ +public final class ServerCompatibility { + + private static final String GRAPHSPACE_MIN_VERSION = "1.7.0"; + private static final String DEFAULT_ROLE_MIN_API_VERSION = "0.72"; + + private ServerCompatibility() { + } + + public static Profile profile(String coreVersion) { + return profile(coreVersion, null); + } + + public static Profile profile(String coreVersion, String apiVersion) { + if (supportsDefaultRoleApi(apiVersion)) { + return Profile.MODERN; + } + if (coreVersion == null || coreVersion.trim().isEmpty()) { + return Profile.LEGACY; + } + try { + String normalized = coreVersion.trim(); + return VersionUtil.gte(normalized, GRAPHSPACE_MIN_VERSION) ? Profile.GRAPHSPACE : Profile.LEGACY; + } catch (RuntimeException ignored) { + return Profile.LEGACY; + } + } + + private static boolean supportsDefaultRoleApi(String apiVersion) { + if (apiVersion == null || apiVersion.trim().isEmpty()) { + return false; + } + try { + return VersionUtil.gte(apiVersion.trim(), + DEFAULT_ROLE_MIN_API_VERSION); + } catch (RuntimeException ignored) { + return false; + } + } + + public static boolean supportsGraphSpace(String coreVersion) { + return profile(coreVersion).supportsGraphSpace(); + } + + public static boolean supportsDefaultRole(String coreVersion, + String apiVersion) { + return profile(coreVersion, apiVersion).supportsDefaultRole(); + } + + public static boolean supportsPersonalProfileUpdate( + String coreVersion, String apiVersion) { + return profile(coreVersion, apiVersion) + .supportsPersonalProfileUpdate(); + } + + public enum Profile { + 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, + boolean personalProfileUpdate) { + this.graphSpace = graphSpace; + this.defaultRole = defaultRole; + this.personalProfileUpdate = personalProfileUpdate; + } + + public boolean supportsGraphSpace() { + return this.graphSpace; + } + + 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 new file mode 100644 index 000000000..6e14e4ecf --- /dev/null +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/HugeClientCompatibilityTest.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.driver; + +import 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; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +public class HugeClientCompatibilityTest { + + private HugeClient client; + private AuthManager auth; + + @Before + public void setup() { + this.client = Mockito.mock(HugeClient.class, + Mockito.CALLS_REAL_METHODS); + this.auth = Mockito.mock(AuthManager.class); + Whitebox.setInternalState(this.client, "auth", this.auth); + } + + @Test + public void shouldUseDirectLookupForModernServers() { + User alice = user("alice"); + Whitebox.setInternalState( + this.client, "compatibility", + ServerCompatibility.Profile.MODERN); + Mockito.when(this.auth.getUserByName("alice")).thenReturn(alice); + + Assert.assertTrue(this.client.supportsDefaultRole()); + Assert.assertSame(alice, this.client.findUserByName("alice")); + Mockito.verify(this.auth, Mockito.never()).listUsers(); + } + + @Test + public void shouldSearchLegacyUserListsByName() { + User alice = user("alice"); + User bob = user("bob"); + Whitebox.setInternalState( + this.client, "compatibility", + ServerCompatibility.Profile.GRAPHSPACE); + Mockito.when(this.auth.listUsers()) + .thenReturn(Arrays.asList(bob, alice)); + + Assert.assertFalse(this.client.supportsDefaultRole()); + Assert.assertSame(alice, this.client.findUserByName("alice")); + Assert.assertNull(this.client.findUserByName("missing")); + Mockito.verify(this.auth, Mockito.never()) + .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); + return user; + } +} diff --git a/hugegraph-client/src/test/java/org/apache/hugegraph/driver/ServerCompatibilityTest.java b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/ServerCompatibilityTest.java new file mode 100644 index 000000000..577071974 --- /dev/null +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/driver/ServerCompatibilityTest.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.driver; + +import org.junit.Assert; +import org.junit.Test; + +public class ServerCompatibilityTest { + + @Test + public void shouldKeepLegacyServersConservative() { + Assert.assertFalse(ServerCompatibility.supportsGraphSpace("1.5.0")); + Assert.assertFalse(ServerCompatibility.supportsGraphSpace("1.6.0")); + Assert.assertFalse(ServerCompatibility.supportsGraphSpace(null)); + Assert.assertFalse(ServerCompatibility.supportsGraphSpace("not-a-version")); + } + + @Test + public void shouldExposeGraphSpaceForModernServers() { + Assert.assertTrue(ServerCompatibility.supportsGraphSpace("1.7.0")); + Assert.assertTrue(ServerCompatibility.supportsGraphSpace(" 1.7.0 ")); + Assert.assertTrue(ServerCompatibility.supportsGraphSpace("1.8.0")); + Assert.assertFalse(ServerCompatibility.supportsDefaultRole( + "1.7.0", "0.71.0.0")); + Assert.assertTrue(ServerCompatibility.supportsDefaultRole( + "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")); + Assert.assertEquals(ServerCompatibility.Profile.MODERN, + ServerCompatibility.profile("1.7.0", + "0.72.0.0")); + Assert.assertEquals(ServerCompatibility.Profile.GRAPHSPACE, + ServerCompatibility.profile("1.7.0", + "not-a-version")); + } +} 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 void testSpaceChecksUseEachTargetGraphSpacePath() { 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-client/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java index ab4c18de3..f48efbcec 100644 --- a/hugegraph-client/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-client/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -17,6 +17,8 @@ package org.apache.hugegraph.unit; +import org.apache.hugegraph.driver.HugeClientCompatibilityTest; +import org.apache.hugegraph.driver.ServerCompatibilityTest; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -33,6 +35,8 @@ ManagerAPITest.class, GraphsAPITest.class, PDHugeClientFactoryTest.class, + HugeClientCompatibilityTest.class, + ServerCompatibilityTest.class, CommonUtilTest.class, IdUtilTest.class, SplicingIdGeneratorTest.class diff --git a/hugegraph-hubble/.prettierrc b/hugegraph-hubble/.prettierrc index afb777869..b6d7333f3 100644 --- a/hugegraph-hubble/.prettierrc +++ b/hugegraph-hubble/.prettierrc @@ -1,6 +1,6 @@ { "singleQuote": true, "tabWidth": 2, - "printWidth": 80, + "printWidth": 120, "trailingComma": "none" } diff --git a/hugegraph-hubble/AGENTS.md b/hugegraph-hubble/AGENTS.md new file mode 100644 index 000000000..9f732a344 --- /dev/null +++ b/hugegraph-hubble/AGENTS.md @@ -0,0 +1,39 @@ +# Hubble contributor guide + +## Authentication and connection boundary + +The `1.8/master` path is the source of truth. Backend configuration exposes +one `auth.enabled` switch and one connection resolver. The resolver chooses +either a direct server URL or an address discovered from PD; callers must not +reimplement `usePD` or infer connection state from page-local flags. In PD mode +the server address returned by discovery is authoritative, so a manual server +URL is not required. + +Use the unauthenticated HugeGraph client for anonymous mode. Do not manufacture +an empty token or an administrator session. Anonymous mode has no account +context and account/permission routes are hidden or rejected at the capability +boundary. + +## Compatibility policy + +Compatibility is intentionally one-way: + +- `1.8/master`: modern GraphSpace/auth contracts and the complete UI. +- `1.7`: thin fallback for the legacy response shape; keep the core workflow + usable without adding version branches to controllers or React pages. +- `1.5` standalone: core graph/schema/data operations only. GraphSpace + management is unsupported and should degrade with an explicit capability + response. Do not add a PD variant for 1.5. + +Version checks belong in the client compatibility adapter and connection +resolver. New code should consume capabilities, not compare literal versions. +When an old image cannot satisfy a capability, mark the test as `needs input` +or `skipped` with the exact image tag and reason. + +## Verification + +For UI changes, use Chrome to exercise login/non-auth mode, connection +switching, and account/GraphSpace visibility. Static inspection and unit tests +are not a substitute for this interaction check. Keep screenshots collected +from the running UI in the documentation assets referenced by +`README.md`. diff --git a/hugegraph-hubble/README.md b/hugegraph-hubble/README.md index cbc13ad04..445b4ee20 100644 --- a/hugegraph-hubble/README.md +++ b/hugegraph-hubble/README.md @@ -7,6 +7,33 @@ hugegraph-hubble is a graph management and analysis platform that provides features: graph data load, schema management, graph relationship analysis, and graphical display. +## Authentication, connections, and compatibility + +Hubble uses one capability-driven connection boundary for `1.8/master`. +`auth.enabled=true` creates an authenticated session; when it is `false`, Hubble +uses an unauthenticated client and does not create a fake user. Account and +permission entry points are hidden in anonymous mode. Connection switching +always goes through the backend resolver. In PD mode, a valid server address +returned by discovery is sufficient; a manually configured server URL is not +required. + +Container and orchestrated deployments can set `HUBBLE_AUTH_ENABLED=true` or +`false`. This explicit runtime value overrides `auth.enabled` from the +properties file, and invalid values fail startup instead of silently selecting +an authentication mode. + +The UI presents four stable permission meanings: super administrator, GraphSpace +read-only, GraphSpace read-write, and GraphSpace administrator. The last one +means member management plus read/write within that GraphSpace; low-level +`role`, `target`, `access`, and `belong` fields are not exposed. + +The compatibility boundary is deliberately small. Server 1.7 uses a thin +legacy-response fallback. Server 1.5 standalone is limited to core graph, +schema, data, and Gremlin operations; GraphSpace management is reported as +unsupported. Version checks stay in the client adapter/resolver rather than +being scattered through controllers or pages. See +[`AGENTS.md`](AGENTS.md) for the support matrix and verification rules. + ## Local development feedback loop Run the frontend with third-party source-map noise disabled: diff --git a/hugegraph-hubble/docs/images/pr27/15-standalone-nonauth-visual.png b/hugegraph-hubble/docs/images/pr27/15-standalone-nonauth-visual.png new file mode 100644 index 000000000..0b472dac9 Binary files /dev/null and b/hugegraph-hubble/docs/images/pr27/15-standalone-nonauth-visual.png differ diff --git a/hugegraph-hubble/docs/images/pr27/18-standalone-nonauth-visual.png b/hugegraph-hubble/docs/images/pr27/18-standalone-nonauth-visual.png new file mode 100644 index 000000000..13e8bc6cf Binary files /dev/null and b/hugegraph-hubble/docs/images/pr27/18-standalone-nonauth-visual.png differ diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/common/Constant.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/common/Constant.java index 7c29e9518..66f94ed6a 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/common/Constant.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/common/Constant.java @@ -58,6 +58,15 @@ public final class Constant { public static final String TOKEN_KEY = "auth_token"; public static final String USERNAME_KEY = "username"; + /** + * Server-side-only legacy Gremlin credentials. 1.7's Gremlin HTTP + * channel accepts Basic auth while its REST APIs accept the login token. + */ + public static final String PASSWORD_KEY = "auth_password"; + public static final String PASSWORD_EXPIRE_AT_KEY = + "auth_password_expire_at"; + public static final String GRAPHSPACE_ACCESS_KEY = + "validated_graphspace"; public static final int NO_LIMIT = -1; diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/HubbleConfig.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/HubbleConfig.java index 8caad2787..ea40f8668 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/HubbleConfig.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/HubbleConfig.java @@ -28,10 +28,14 @@ import java.io.File; import java.net.URL; +import java.util.Locale; +import java.util.Map; @Configuration public class HubbleConfig { + static final String AUTH_ENABLED_ENV = "HUBBLE_AUTH_ENABLED"; + @Autowired private ApplicationArguments arguments; @@ -56,6 +60,24 @@ public HugeConfig hugeConfig() { conf = path; } } - return new HugeConfig(conf); + HugeConfig config = new HugeConfig(conf); + applyEnvironmentOverrides(config, System.getenv()); + return config; + } + + static void applyEnvironmentOverrides(HugeConfig config, + Map environment) { + String authEnabled = environment.get(AUTH_ENABLED_ENV); + if (authEnabled == null) { + return; + } + + String normalized = authEnabled.trim().toLowerCase(Locale.ROOT); + if (!normalized.equals("true") && !normalized.equals("false")) { + throw new ExternalException( + AUTH_ENABLED_ENV + " must be true or false"); + } + config.setProperty(HubbleOptions.AUTH_ENABLED.name(), + Boolean.valueOf(normalized)); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/WebMvcConfig.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/WebMvcConfig.java index ec0fb77e8..f0a2950b7 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/WebMvcConfig.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/config/WebMvcConfig.java @@ -18,6 +18,7 @@ package org.apache.hugegraph.config; +import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.handler.LoginInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -66,6 +67,7 @@ public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(this.loginInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/**/auth/login") + .excludePathPatterns(Constant.API_VERSION + "config") .excludePathPatterns("/logout") .excludePathPatterns("/api/**/auth/logout"); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java index 807da39fc..eb7cffa4f 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java @@ -21,11 +21,15 @@ import java.util.List; import java.util.function.Function; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.driver.factory.PDHugeClientFactory; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.service.auth.AuthModeService; +import org.apache.hugegraph.service.auth.AuthContextService; +import org.apache.hugegraph.service.space.GraphSpaceService; import org.apache.commons.collections.CollectionUtils; import org.apache.hugegraph.config.HugeConfig; import org.springframework.beans.factory.annotation.Autowired; @@ -59,6 +63,12 @@ public abstract class BaseController { @Autowired protected UserService userService; + @Autowired + protected AuthModeService authMode; + @Autowired + protected AuthContextService authContextService; + @Autowired + protected GraphSpaceService graphSpaceAccessService; public static final String ORDER_ASC = "asc"; public static final String ORDER_DESC = "desc"; @@ -134,22 +144,31 @@ protected void delToken() { protected void clearAuthSession() { this.delSession(Constant.TOKEN_KEY); this.delSession(Constant.USERNAME_KEY); + this.delSession(Constant.PASSWORD_KEY); + this.delSession(Constant.PASSWORD_EXPIRE_AT_KEY); } protected HugeClient authClient(String graphSpace, String graph) { HttpServletRequest request = getRequest(); if (request.getAttribute("hugeClient") != null) { HugeClient client = (HugeClient) request.getAttribute("hugeClient"); + this.requireGraphSpaceAccess(client, graphSpace); client.assignGraph(graphSpace, graph); return client; } - HugeClient client = this.hugeClientPoolService.createAuthClient( - graphSpace, graph, this.getToken()); + HugeClient client = this.authMode != null && this.authMode.anonymous() ? + this.hugeClientPoolService.createUnauthClient(graphSpace, graph) : + this.hugeClientPoolService.createAuthClient(graphSpace, graph, this.getToken()); + this.requireGraphSpaceAccess(client, graphSpace); + if (graphSpace != null || graph != null) { + client.assignGraph(graphSpace, graph); + } request.setAttribute("hugeClient", client); return client; } protected HugeClient requireAccountManager() { + this.requireAuthenticatedAuthorization(); HugeClient client = this.authClient(null, null); String level = this.userService.userLevel(client, this.getUser()); if (!"ADMIN".equals(level)) { @@ -159,6 +178,7 @@ protected HugeClient requireAccountManager() { } protected HugeClient requireGraphSpaceManager(String graphSpace) { + this.requireAuthenticatedAuthorization(); HugeClient client = this.authClient(null, null); if (!this.userService.isSuperAdmin(client) && !this.userService.isAssignSpaceAdmin(client, graphSpace)) { @@ -169,7 +189,28 @@ protected HugeClient requireGraphSpaceManager(String graphSpace) { return client; } + protected HugeClient requireGraphSpaceWrite(String graphSpace) { + HugeClient client = this.authClient(null, null); + this.requireGraphSpaceAccess(client, graphSpace); + this.authContextService.requireGraphSpaceWrite( + client, this.getUser(), graphSpace); + client.assignGraph(graphSpace, null); + return client; + } + + protected HugeClient requireGraphSpaceAuthorizationAdmin( + String graphSpace) { + this.requireAuthenticatedAuthorization(); + HugeClient client = this.authClient(null, null); + if (!this.userService.isSuperAdmin(client)) { + throw new ForbiddenException("Permission denied: manage authorization objects"); + } + client.assignGraph(graphSpace, null); + return client; + } + protected HugeClient requireGraphSpaceAdministrator() { + this.requireAuthenticatedAuthorization(); HugeClient client = this.authClient(null, null); if (!this.userService.isSuperAdmin(client)) { throw new ForbiddenException( @@ -179,7 +220,52 @@ protected HugeClient requireGraphSpaceAdministrator() { } protected HugeClient authGremlinClient(String graphSpace, String graph) { - return this.authClient(graphSpace, graph); + if (this.authMode != null && this.authMode.anonymous()) { + return this.authClient(graphSpace, graph); + } + + HttpServletRequest request = this.getRequest(); + HttpSession session = request.getSession(false); + if (session == null) { + return this.authClient(graphSpace, graph); + } + + String username = (String) session.getAttribute(Constant.USERNAME_KEY); + String token = (String) session.getAttribute(Constant.TOKEN_KEY); + String password = this.validSessionPassword(session); + if (!StringUtils.hasText(username) || !StringUtils.hasText(token) || + !StringUtils.hasText(password)) { + return this.authClient(graphSpace, graph); + } + + Object existing = request.getAttribute("hugeClient"); + if (existing instanceof HugeClient) { + ((HugeClient) existing).close(); + } + HugeClient client = this.createBasicClient(graphSpace, graph, + username, password); + this.requireGraphSpaceAccess(client, graphSpace); + request.setAttribute("hugeClient", client); + return client; + } + + protected HugeClient createBasicClient(String graphSpace, String graph, + String username, String password) { + return this.hugeClientPoolService.createBasicClient( + graphSpace, graph, username, password); + } + + private String validSessionPassword(HttpSession session) { + Object password = session.getAttribute(Constant.PASSWORD_KEY); + Object expiresAt = session.getAttribute( + Constant.PASSWORD_EXPIRE_AT_KEY); + if (!(password instanceof String) || !(expiresAt instanceof Number) || + System.currentTimeMillis() >= ((Number) expiresAt).longValue()) { + session.removeAttribute(Constant.PASSWORD_KEY); + session.removeAttribute(Constant.PASSWORD_EXPIRE_AT_KEY); + return null; + } + return (String) password; } protected HugeClient unauthClient() { @@ -266,10 +352,36 @@ protected HugeClient defaultClient(String graphSpace, String graph) { HugeClient client = hugeClientPoolService.create(url, graphSpace, graph, this.getToken()); - + this.requireGraphSpaceAccess(client, graphSpace); return client; } + private void requireAuthenticatedAuthorization() { + if (this.authMode != null && this.authMode.anonymous()) { + throw new ForbiddenException( + "Authentication is required for this operation"); + } + } + + protected void requireGraphSpaceAccess(HugeClient client, + String graphSpace) { + if (graphSpace == null || !config.get(HubbleOptions.PD_ENABLED)) { + return; + } + HttpServletRequest request = getRequest(); + if (graphSpace.equals( + request.getAttribute(Constant.GRAPHSPACE_ACCESS_KEY))) { + return; + } + if (this.authMode != null && this.authMode.anonymous()) { + this.graphSpaceAccessService.requirePublicSpace(client, + graphSpace); + } else { + this.graphSpaceAccessService.requireAccessibleSpace(client, + graphSpace); + } + } + public String getUrl() { boolean pdEnabled = config.get(HubbleOptions.PD_ENABLED); if (!pdEnabled) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java index 5b3795d8a..8f8f5cb6c 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ConfigController.java @@ -39,11 +39,8 @@ public class ConfigController { @GetMapping public Map getConfig() { Map result = new HashMap<>(); - boolean pdEnabled = config.get(HubbleOptions.PD_ENABLED); - result.put("pd_enabled", pdEnabled); - if (!pdEnabled) { - result.put("server_url", config.get(HubbleOptions.SERVER_URL)); - } + result.put("pd_enabled", config.get(HubbleOptions.PD_ENABLED)); + result.put("auth_enabled", config.get(HubbleOptions.AUTH_ENABLED)); return result; } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/algorithm/OltpAlgoController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/algorithm/OltpAlgoController.java index c50d77ab0..05408442d 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/algorithm/OltpAlgoController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/algorithm/OltpAlgoController.java @@ -95,7 +95,7 @@ public GremlinResult shortPathAlias(@PathVariable("graphspace") String graphSpac public GremlinResult rings(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody RingsEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.rings(client, body); } @@ -103,7 +103,7 @@ public GremlinResult rings(@PathVariable("graphspace") String graphSpace, public GremlinResult advancedPaths(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody PathsRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.advancedpaths(client, body); } @@ -111,7 +111,7 @@ public GremlinResult advancedPaths(@PathVariable("graphspace") String graphSpace public GremlinResult sameNeighbors(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody SameNeighborsEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.sameNeighbors(client, body); } @@ -119,7 +119,7 @@ public GremlinResult sameNeighbors(@PathVariable("graphspace") String graphSpace public GremlinResult kout(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody KoutEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.kout(client, body); } @@ -127,7 +127,7 @@ public GremlinResult kout(@PathVariable("graphspace") String graphSpace, public GremlinResult koutPost(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody KoutRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.koutPost(client, body); } @@ -135,7 +135,7 @@ public GremlinResult koutPost(@PathVariable("graphspace") String graphSpace, public GremlinResult kneighbor(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody KneighborEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.kneighbor(client, body); } @@ -143,7 +143,7 @@ public GremlinResult kneighbor(@PathVariable("graphspace") String graphSpace, public GremlinResult kneighborPost(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody KneighborRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.kneighborPost(client, body); } @@ -151,7 +151,7 @@ public GremlinResult kneighborPost(@PathVariable("graphspace") String graphSpace public JaccardsimilarityView jaccardSimilarity(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody JaccardSimilarityEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.jaccardSimilarity(client, body); } @@ -160,7 +160,7 @@ public JaccardsimilarityView jaccardSimilarityPost( @PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody SingleSourceJaccardSimilarityRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.jaccardSimilarityPost(client, body); } @@ -168,7 +168,7 @@ public JaccardsimilarityView jaccardSimilarityPost( public RanksView personalRank(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody PersonalRankAPI.Request body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.personalRank(client, body); } @@ -176,7 +176,7 @@ public RanksView personalRank(@PathVariable("graphspace") String graphSpace, public RanksView neighborRank(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody NeighborRankAPI.Request body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.neighborRank(client, body); } @@ -184,7 +184,7 @@ public RanksView neighborRank(@PathVariable("graphspace") String graphSpace, public GremlinResult allShortPaths(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody AllShortestPathsEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.allShortestPaths(client, body); } @@ -199,7 +199,7 @@ public GremlinResult allShortPathAlias(@PathVariable("graphspace") String graphS public GremlinResult weightedShortestPath(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody WeightedShortestPathEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.weightedShortestPath(client, body); } @@ -208,7 +208,7 @@ public GremlinResult singleSourceShortestPath( @PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody SingleSourceShortestPathEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.singleSourceShortestPath(client, body); } @@ -216,7 +216,7 @@ public GremlinResult singleSourceShortestPath( public GremlinResult multiNodeShortestPath(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody MultiNodeShortestPathRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.multiNodeShortestPath(client, body); } @@ -224,7 +224,7 @@ public GremlinResult multiNodeShortestPath(@PathVariable("graphspace") String gr public GremlinResult paths(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody PathsEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.paths(client, body); } @@ -232,7 +232,7 @@ public GremlinResult paths(@PathVariable("graphspace") String graphSpace, public GremlinResult customizedPaths(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody CustomizedPathsRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.customizedPaths(client, body); } @@ -240,7 +240,7 @@ public GremlinResult customizedPaths(@PathVariable("graphspace") String graphSpa public GremlinResult templatePaths(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody TemplatePathsRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.templatePaths(client, body); } @@ -248,7 +248,7 @@ public GremlinResult templatePaths(@PathVariable("graphspace") String graphSpace public GremlinResult crosspoints(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody CrossPointsEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.crosspoints(client, body); } @@ -256,7 +256,7 @@ public GremlinResult crosspoints(@PathVariable("graphspace") String graphSpace, public GremlinResult customizedcrosspoints(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody CrosspointsRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.customizedcrosspoints(client, body); } @@ -264,7 +264,7 @@ public GremlinResult customizedcrosspoints(@PathVariable("graphspace") String gr public GremlinResult rays(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody RaysEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.rays(client, body); } @@ -272,7 +272,7 @@ public GremlinResult rays(@PathVariable("graphspace") String graphSpace, public FusiformsimilarityView fusiformsimilarity(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody FusiformSimilarityRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.fusiformsimilarity(client, body); } @@ -280,7 +280,7 @@ public FusiformsimilarityView fusiformsimilarity(@PathVariable("graphspace") Str public Map adamicadar(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody AdamicadarEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.adamicadar(client, body); } @@ -288,7 +288,7 @@ public Map adamicadar(@PathVariable("graphspace") String graphSp public Map resourceallocation(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody ResourceallocationEntity body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.resourceallocation(client, body); } @@ -296,7 +296,7 @@ public Map resourceallocation(@PathVariable("graphspace") String public GremlinResult sameneighborsbatch(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody SameNeighborsBatchRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.sameneighborsbatch(client, body); } @@ -304,7 +304,7 @@ public GremlinResult sameneighborsbatch(@PathVariable("graphspace") String graph public EgonetView egonet(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody EgonetRequest body) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); return this.service.egonet(client, body); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java index 0f97026ed..f2bbc78f5 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/AccessController.java @@ -47,28 +47,28 @@ public List list( @PathVariable("graphspace") String graphSpace, @RequestParam(value = "role_id", required = false) String roleId, @RequestParam(value = "target_id", required = false) String targetId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.accessService.list(client, graphSpace, roleId, targetId); } @GetMapping("{id}") public AccessEntity get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String accessId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.accessService.get(client, graphSpace, accessId); } @PostMapping public AccessEntity add(@PathVariable("graphspace") String graphSpace, @RequestBody AccessEntity accessEntity) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.accessService.addOrUpdate(client, graphSpace, accessEntity); } @PutMapping public AccessEntity update(@PathVariable("graphspace") String graphSpace, @RequestBody AccessEntity accessEntity) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.accessService.addOrUpdate(client, graphSpace, accessEntity); } @@ -76,7 +76,7 @@ public AccessEntity update(@PathVariable("graphspace") String graphSpace, public void delete(@PathVariable("graphspace") String graphSpace, @RequestParam("role_id") String roleId, @RequestParam("target_id") String targetId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.accessService.delete(client, graphSpace, roleId, targetId); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java index 757a4152b..1f7ce5660 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/BelongController.java @@ -48,7 +48,7 @@ public List list( @PathVariable("graphspace") String graphSpace, @RequestParam(value = "role_id", required = false) String roleId, @RequestParam(value = "user_id", required = false) String userId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.belongService.list(client, graphSpace, roleId, userId); } @@ -61,7 +61,7 @@ public IPage listPage( defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.belongService.listPage(client, graphSpace, roleId, userId, pageNo, pageSize); } @@ -69,14 +69,14 @@ public IPage listPage( @GetMapping("{id}") public BelongEntity get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String belongId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.belongService.get(client, graphSpace, belongId); } @PostMapping public void create(@PathVariable("graphspace") String graphSpace, @RequestBody BelongEntity belongEntity) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.belongService.add(client, graphSpace, belongEntity.getRoleId(), belongEntity.getUserId()); } @@ -84,7 +84,7 @@ public void create(@PathVariable("graphspace") String graphSpace, @PostMapping("ids") public void createMany(@PathVariable("graphspace") String graphSpace, @RequestBody BelongService.BelongsReq belongsReq) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); for (String userId : belongsReq.getUserIds()) { this.belongService.add(client, graphSpace, belongsReq.getRoleId(), userId); @@ -94,7 +94,7 @@ public void createMany(@PathVariable("graphspace") String graphSpace, @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String belongId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.belongService.deleteById(client, graphSpace, belongId); } @@ -102,7 +102,7 @@ public void delete(@PathVariable("graphspace") String graphSpace, public void delete(@PathVariable("graphspace") String graphSpace, @RequestParam("role_id") String roleId, @RequestParam("user_id") String userId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); if (StringUtils.isNotEmpty(roleId) && StringUtils.isNotEmpty(userId)) { this.belongService.delete(client, graphSpace, roleId, userId); } @@ -111,7 +111,7 @@ public void delete(@PathVariable("graphspace") String graphSpace, @PostMapping("delids") public void deleteMany(@PathVariable("graphspace") String graphSpace, @RequestBody DelIdsReq delIdsReq) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.belongService.deleteMany(client, graphSpace, delIdsReq.ids.toArray(new String[0])); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/GraphSpaceUserController.java index 8ec43ae5a..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 @@ -18,6 +18,8 @@ package org.apache.hugegraph.controller.auth; +import java.util.Map; + import com.baomidou.mybatisplus.core.metadata.IPage; import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.driver.HugeClient; @@ -25,6 +27,7 @@ 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; @@ -82,22 +85,45 @@ public UserView get(@PathVariable("graphspace") String graphSpace, public UserManager setGraphSpaceAdmin( @PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); - return client.auth().addSpaceAdmin(userId, graphSpace); + HugeClient client = + this.requireGraphSpaceAuthorizationAdmin(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 identity, + @RequestBody Map body) { + String preset = body.get("permission_preset"); + String userId = body.get("user_id"); + String username = body.get("username"); + HugeClient client = this.requirePresetManager(graphSpace, username, + 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}") public void removeGraphSpaceAdmin( @PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); - client.auth().delSpaceAdmin(userId, graphSpace); + HugeClient client = + this.requireGraphSpaceAuthorizationAdmin(graphSpace); + User account = client.auth().getUser(userId); + E.checkNotNull(account, "User"); + client.auth().delSpaceAdmin(account.name(), graphSpace); } @PostMapping public UserView create(@PathVariable("graphspace") String graphSpace, @RequestBody UserView userView) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = + this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.userService.createOrUpdate(client, graphSpace, userView); } @@ -105,7 +131,8 @@ public UserView create(@PathVariable("graphspace") String graphSpace, public UserView createOrUpdate(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId, @RequestBody UserView userView) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = + this.requireGraphSpaceAuthorizationAdmin(graphSpace); userView.setId(userId); return this.userService.createOrUpdate(client, graphSpace, userView); } @@ -113,7 +140,37 @@ public UserView createOrUpdate(@PathVariable("graphspace") String graphSpace, @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String userId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireMemberManager(graphSpace, userId); this.userService.unauthUser(client, graphSpace, userId); } + + private HugeClient requirePresetManager(String graphSpace, String username, + String preset) { + if ("GS_ADMIN".equals(preset)) { + return this.requireGraphSpaceAuthorizationAdmin(graphSpace); + } + return this.requireMemberManagerByUsername(graphSpace, username); + } + + private HugeClient requireMemberManager(String graphSpace, + String userId) { + HugeClient client = this.requireGraphSpaceManager(graphSpace); + 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/auth/LoginController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java index b59b24b74..955909cf4 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/LoginController.java @@ -75,6 +75,10 @@ public class LoginController extends BaseController { @PostMapping("/login") public Object login(@RequestBody Login login) { + if (this.authMode != null && this.authMode.anonymous()) { + throw new ExternalException(HttpStatus.FORBIDDEN.value(), + "Authentication is disabled"); + } String address = this.getRequest().getRemoteAddr(); boolean pdEnabled = this.config.get(HubbleOptions.PD_ENABLED); this.loginAttemptGuard.checkAllowed(login.name(), address); @@ -100,6 +104,13 @@ public Object login(@RequestBody Login login) { this.getRequest().changeSessionId(); this.setUser(login.name()); this.setToken(result.token()); + // HugeGraph 1.7's Gremlin HTTP channel only accepts Basic auth. + // Keep the credential server-side for the session lifetime so + // graph queries can use the same identity as REST requests. + this.setSession(Constant.PASSWORD_KEY, login.password()); + this.setSession(Constant.PASSWORD_EXPIRE_AT_KEY, + System.currentTimeMillis() + + TOKEN_EXPIRE_SECONDS * 1000L); return user; } catch (Throwable e) { this.clearAuthSession(); @@ -225,6 +236,9 @@ private static UserEntity currentUser(String username) { @GetMapping("/status") public Object status() { + if (this.authMode != null && this.authMode.anonymous()) { + return ImmutableMap.of("level", "ANONYMOUS"); + } HugeClient client = authClient(null, null); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java index 06e0e268f..f7ed393f6 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/RoleController.java @@ -46,7 +46,7 @@ public class RoleController extends AuthController { @GetMapping("list") public List listName(@PathVariable("graphspace") String graphSpace) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.roleService.list(client, graphSpace, this.userService.isSuperAdmin(client)); } @@ -60,7 +60,7 @@ public IPage queryPage( defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.roleService.queryPage( client, graphSpace, query, pageNo, pageSize, this.userService.isSuperAdmin(client)); @@ -69,7 +69,7 @@ public IPage queryPage( @GetMapping("{id}") public Role get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String roleId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.roleService.get(client, graphSpace, roleId, this.userService.isSuperAdmin(client)); } @@ -77,7 +77,7 @@ public Role get(@PathVariable("graphspace") String graphSpace, @PostMapping public Role add(@PathVariable("graphspace") String graphSpace, @RequestBody Role role) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); role.graphSpace(graphSpace); return this.roleService.insert(client, graphSpace, role); } @@ -86,7 +86,7 @@ public Role add(@PathVariable("graphspace") String graphSpace, public Role update(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String id, @RequestBody Map body) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); boolean includeLegacy = this.userService.isSuperAdmin(client); Role current = this.roleService.get(client, graphSpace, id, includeLegacy); @@ -108,7 +108,7 @@ public Role update(@PathVariable("graphspace") String graphSpace, @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String id) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.roleService.delete(client, graphSpace, id, this.userService.isSuperAdmin(client)); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java index a11368d11..a3d25e3c5 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/auth/TargetController.java @@ -45,7 +45,7 @@ public class TargetController extends AuthController { @GetMapping("list") public List list(@PathVariable("graphspace") String graphSpace) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.targetService.list(client, graphSpace); } @@ -58,7 +58,7 @@ public IPage queryPage( defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.targetService.queryPage(client, graphSpace, query, pageNo, pageSize); } @@ -66,14 +66,14 @@ public IPage queryPage( @GetMapping("{id}") public Target get(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.targetService.get(client, graphSpace, targetId); } @PostMapping public Target add(@PathVariable("graphspace") String graphSpace, @RequestBody Target target) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); return this.targetService.add(client, graphSpace, target); } @@ -81,7 +81,7 @@ public Target add(@PathVariable("graphspace") String graphSpace, public Target update(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId, @RequestBody Target target) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); Target current = this.targetService.get(client, graphSpace, targetId); current.resources(target.resources()); current.description(target.description()); @@ -91,7 +91,7 @@ public Target update(@PathVariable("graphspace") String graphSpace, @DeleteMapping("{id}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("id") String targetId) { - HugeClient client = this.requireGraphSpaceManager(graphSpace); + HugeClient client = this.requireGraphSpaceAuthorizationAdmin(graphSpace); this.targetService.delete(client, graphSpace, targetId); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java index 8fb8ac697..8e2bef18f 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/graphs/GraphsController.java @@ -333,9 +333,13 @@ public Map graphReadMode( public Object clone(@PathVariable("graphspace") String graphspace, @PathVariable("graph") String graph, @RequestBody GraphCloneEntity graphCloneEntity) { - return this.graphsService.clone(this.authClient(graphspace, graph), - graphCloneEntity.convertMap(graphspace, - graph)); + HugeClient client = this.authClient(graphspace, graph); + String targetGraphSpace = graphCloneEntity.getGraphSpace() == null ? + graphspace : + graphCloneEntity.getGraphSpace(); + this.requireGraphSpaceAccess(client, targetGraphSpace); + return this.graphsService.clone( + client, graphCloneEntity.convertMap(graphspace, graph)); } // //@Data diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java index 7b7f97050..3fadd9af0 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/ingest/IngestController.java @@ -283,7 +283,8 @@ public Response createTask(@RequestBody IngestTaskRequest request) { mapping.setEdgeMappings(edgeMappings); GraphConnection connection = this.graphConnection(graphSpace, graph); - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.requireGraphSpaceWrite(graphSpace); + client.assignGraph(graphSpace, graph); LoadTask task = this.jobManagerService.createIngestTask( job, mapping, connection, client); Map data = new HashMap<>(); @@ -299,9 +300,7 @@ public Response taskList( @RequestParam(name = "page_no", required = false, defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { - // list all jobs across all graphspaces - use empty strings to get all - // We need to query without graphspace/graph filter for the ingest view - IPage page = jobManagerService.listAll(pageNo, pageSize, query); + IPage page = this.visibleJobPage(pageNo, pageSize, query); IPage result = page.convert(job -> { TaskVO vo = new TaskVO(); @@ -367,11 +366,18 @@ public Response taskDetail(@PathVariable("id") int id) { return Response.builder().status(Constant.STATUS_NOT_FOUND) .message("Task not found: " + id).build(); } + this.requireJobAccess(job); return Response.builder().status(Constant.STATUS_OK).data(job).build(); } @DeleteMapping("/tasks/{id}") public Response deleteTask(@PathVariable("id") int id) { + JobManager job = jobManagerService.get(id); + if (job == null) { + return Response.builder().status(Constant.STATUS_NOT_FOUND) + .message("Task not found: " + id).build(); + } + this.requireJobWrite(job); jobManagerService.remove(id); return Response.builder().status(Constant.STATUS_OK).build(); } @@ -383,6 +389,7 @@ public Response enableTask(@PathVariable("id") int id) { return Response.builder().status(Constant.STATUS_NOT_FOUND) .message("Task not found: " + id).build(); } + this.requireJobWrite(job); job.setJobStatus(JobStatus.DEFAULT); jobManagerService.update(job); return Response.builder().status(Constant.STATUS_OK).build(); @@ -395,6 +402,7 @@ public Response disableTask(@PathVariable("id") int id) { return Response.builder().status(Constant.STATUS_NOT_FOUND) .message("Task not found: " + id).build(); } + this.requireJobWrite(job); job.setJobStatus(JobStatus.FAILED); jobManagerService.update(job); return Response.builder().status(Constant.STATUS_OK).build(); @@ -408,6 +416,12 @@ public Response jobList( @RequestParam(name = "page_no", required = false, defaultValue = "1") int pageNo, @RequestParam(name = "page_size", required = false, defaultValue = "10") int pageSize) { + JobManager job = jobManagerService.get(taskId); + if (job == null) { + return Response.builder().status(Constant.STATUS_NOT_FOUND) + .message("Task not found: " + taskId).build(); + } + this.requireJobAccess(job); List tasks = loadTaskService.taskListByJob(taskId); // Manual pagination @@ -453,11 +467,18 @@ public Response jobDetail(@PathVariable("id") int id) { return Response.builder().status(Constant.STATUS_NOT_FOUND) .message("Job not found: " + id).build(); } + this.requireLoadTaskAccess(task); return Response.builder().status(Constant.STATUS_OK).data(task).build(); } @DeleteMapping("/jobs/{id}") public Response deleteJob(@PathVariable("id") int id) { + LoadTask task = loadTaskService.get(id); + if (task == null) { + return Response.builder().status(Constant.STATUS_NOT_FOUND) + .message("Job not found: " + id).build(); + } + this.requireLoadTaskWrite(task); loadTaskService.remove(id); return Response.builder().status(Constant.STATUS_OK).build(); } @@ -466,7 +487,7 @@ public Response deleteJob(@PathVariable("id") int id) { @GetMapping("/metrics/task") public Response metricsTask() { - List all = jobManagerService.listAll(); + List all = this.visibleJobs(""); all.forEach(jobManagerService::refreshStatus); long runningOnce = 0; @@ -506,6 +527,75 @@ public Response metricsTask() { // ===== Helpers ===== + private IPage visibleJobPage(int pageNo, int pageSize, + String query) { + Set graphSpaces = this.visibleGraphSpaces(); + IPage page = this.jobManagerService.listByGraphSpaces( + graphSpaces, pageNo, pageSize, query); + page.getRecords().forEach(jobManagerService::refreshStatus); + return page; + } + + private List visibleJobs(String query) { + Set graphSpaces = this.visibleGraphSpaces(); + List jobs = + this.jobManagerService.listByGraphSpaces(graphSpaces); + if (StringUtils.isEmpty(query)) { + return jobs; + } + return jobs.stream() + .filter(job -> StringUtils.contains(job.getJobName(), query)) + .collect(Collectors.toList()); + } + + private Set visibleGraphSpaces() { + if (this.config == null || + !this.config.get(HubbleOptions.PD_ENABLED)) { + return null; + } + HugeClient client = this.authClient(null, null); + if (this.authMode != null && this.authMode.anonymous()) { + return new LinkedHashSet<>( + this.graphSpaceAccessService.listAnonymous(client)); + } + if (this.userService.isSuperAdmin(client)) { + return null; + } + return new LinkedHashSet<>( + this.graphSpaceAccessService.listAccessible(client)); + } + + private void requireJobAccess(JobManager job) { + if (job == null) { + return; + } + this.requireGraphSpaceAccess(this.authClient(null, null), + job.getGraphSpace()); + } + + private void requireJobWrite(JobManager job) { + if (job == null) { + return; + } + this.requireGraphSpaceWrite(job.getGraphSpace()); + } + + private void requireLoadTaskAccess(LoadTask task) { + JobManager job = task.getJobId() == null ? null : + this.jobManagerService.get(task.getJobId()); + Ex.check(job != null, "job-manager.not-exist.id", + task.getJobId()); + this.requireJobAccess(job); + } + + private void requireLoadTaskWrite(LoadTask task) { + JobManager job = task.getJobId() == null ? null : + this.jobManagerService.get(task.getJobId()); + Ex.check(job != null, "job-manager.not-exist.id", + task.getJobId()); + this.requireJobWrite(job); + } + /** * Same format-whitelist check as * FileUploadController#checkFileValid diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/langchain/LangChainController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/langchain/LangChainController.java index 8c1695fe0..3e39a0eb8 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/langchain/LangChainController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/langchain/LangChainController.java @@ -184,7 +184,7 @@ public Object gremlin(@PathVariable("graphspace") String graphSpace, requestLangChainParams.userName, requestLangChainParams.password); try { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); JsonView result = this.queryService.executeSingleGremlinQuery(client, query); return result.getData(); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileMappingController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileMappingController.java index 39674b59e..43381c508 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileMappingController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileMappingController.java @@ -112,6 +112,7 @@ public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @PathVariable("id") int id) { + this.requireGraphSpaceWrite(graphSpace); FileMapping mapping = this.service.get(graphSpace, graph, jobId, id); if (mapping == null) { throw new ExternalException("load.file-mapping.not-exist.id", id); @@ -125,6 +126,7 @@ public void delete(@PathVariable("graphspace") String graphSpace, public void clear(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId) { + this.requireGraphSpaceWrite(graphSpace); List mappings = this.service.listByJob(graphSpace, graph, jobId); Set fileIds = new HashSet<>(); @@ -143,6 +145,7 @@ public FileMapping fileSetting(@PathVariable("graphspace") String graphSpace, @PathVariable("jobId") int jobId, @PathVariable("id") int id, @RequestBody FileSetting newEntity) { + this.requireGraphSpaceWrite(graphSpace); Ex.check(!StringUtils.isEmpty(newEntity.getDelimiter()), "load.file-mapping.file-setting.delimiter-cannot-be-empty"); Ex.check(!StringUtils.isEmpty(newEntity.getCharset()), @@ -175,6 +178,7 @@ public FileMapping addVertexMapping(@PathVariable("graphspace") String graphSpac @PathVariable("jobId") int jobId, @PathVariable("id") int id, @RequestBody VertexMapping newEntity) { + this.requireGraphSpaceWrite(graphSpace); FileMapping mapping = this.service.get(graphSpace, graph, jobId, id); if (mapping == null) { throw new ExternalException("load.file-mapping.not-exist.id", id); @@ -195,6 +199,7 @@ public FileMapping updateVertexMapping(@PathVariable("graphspace") String graphS @PathVariable("id") int id, @PathVariable("vmid") String vmId, @RequestBody VertexMapping newEntity) { + this.requireGraphSpaceWrite(graphSpace); FileMapping mapping = this.service.get(graphSpace, graph, jobId, id); if (mapping == null) { throw new ExternalException("load.file-mapping.not-exist.id", id); @@ -221,6 +226,7 @@ public FileMapping deleteVertexMapping( @PathVariable("jobId") int jobId, @PathVariable("id") int id, @PathVariable("vmid") String vmid) { + this.requireGraphSpaceWrite(graphSpace); FileMapping mapping = this.service.get(graphSpace, graph, jobId, id); if (mapping == null) { throw new ExternalException("load.file-mapping.not-exist.id", id); @@ -242,6 +248,7 @@ public FileMapping addEdgeMapping(@PathVariable("graphspace") String graphSpace, @PathVariable("jobId") int jobId, @PathVariable("id") int id, @RequestBody EdgeMapping newEntity) { + this.requireGraphSpaceWrite(graphSpace); FileMapping mapping = this.service.get(graphSpace, graph, jobId, id); if (mapping == null) { throw new ExternalException("load.file-mapping.not-exist.id", id); @@ -262,6 +269,7 @@ public FileMapping updateEdgeMapping(@PathVariable("graphspace") String graphSpa @PathVariable("id") int id, @PathVariable("emid") String emId, @RequestBody EdgeMapping newEntity) { + this.requireGraphSpaceWrite(graphSpace); FileMapping mapping = this.service.get(graphSpace, graph, jobId, id); if (mapping == null) { throw new ExternalException("load.file-mapping.not-exist.id", id); @@ -288,6 +296,7 @@ public FileMapping deleteEdgeMapping( @PathVariable("jobId") int jobId, @PathVariable("id") int id, @PathVariable("emid") String emid) { + this.requireGraphSpaceWrite(graphSpace); FileMapping mapping = this.service.get(graphSpace, graph, jobId, id); if (mapping == null) { throw new ExternalException("load.file-mapping.not-exist.id", id); @@ -312,6 +321,7 @@ public void loadParameter(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @RequestBody LoadParameter newEntity) { + this.requireGraphSpaceWrite(graphSpace); this.checkLoadParameter(newEntity); List mappings = this.service.listByJob(graphSpace, graph, jobId); @@ -327,6 +337,7 @@ public void loadParameter(@PathVariable("graphspace") String graphSpace, public JobManager nextStep(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId) { + this.requireGraphSpaceWrite(graphSpace); JobManager jobEntity = this.jobService.get(graphSpace, graph, jobId); Ex.check(jobEntity != null, "job-manager.not-exist.id", jobId); Ex.check(jobEntity.getJobStatus() == JobStatus.MAPPING, diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileUploadController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileUploadController.java index 99c36da84..cbbe7cbc6 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileUploadController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/FileUploadController.java @@ -87,6 +87,7 @@ public Map fileToken( @PathVariable("jobId") int jobId, @RequestParam("names") List fileNames) { + this.requireGraphSpaceWrite(graphSpace); Ex.check(this.jobService.get(graphSpace, graph, jobId) != null, "job-manager.not-exist.id", jobId); Ex.check(CollectionUtil.allUnique(fileNames), @@ -126,6 +127,7 @@ public FileUploadResult upload(@PathVariable("graphspace") String graphSpace, @RequestParam("token") String token, @RequestParam("total") int total, @RequestParam("index") int index) { + this.requireGraphSpaceWrite(graphSpace); this.checkTotalAndIndexValid(total, index); this.checkFileNameValid(fileName); this.checkFileNameMatchToken(fileName, token); @@ -253,6 +255,7 @@ public Boolean delete(@PathVariable("graphspace") String graphSpace, @PathVariable("jobId") int jobId, @RequestParam("name") String fileName, @RequestParam("token") String token) { + this.requireGraphSpaceWrite(graphSpace); this.checkFileNameValid(fileName); JobManager jobEntity = this.jobService.get(graphSpace, graph, jobId); Ex.check(jobEntity != null, "job-manager.not-exist.id", jobId); @@ -286,6 +289,7 @@ public Boolean delete(@PathVariable("graphspace") String graphSpace, public JobManager nextStep(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId) { + this.requireGraphSpaceWrite(graphSpace); JobManager jobEntity = this.jobService.get(graphSpace, graph, jobId); Ex.check(jobEntity != null, "job-manager.not-exist.id", jobId); Ex.check(jobEntity.getJobStatus() == JobStatus.UPLOADING, diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java index f3c58a3e5..a8fb2e731 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/JobManagerController.java @@ -23,6 +23,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.common.Response; +import org.apache.hugegraph.controller.BaseController; import org.apache.hugegraph.entity.enums.JobStatus; import org.apache.hugegraph.entity.enums.LoadStatus; import org.apache.hugegraph.entity.load.FileMapping; @@ -54,7 +55,7 @@ @RestController @RequestMapping(Constant.API_VERSION + "graphspaces/{graphspace}/graphs" + "/{graph}/job-manager") -public class JobManagerController { +public class JobManagerController extends BaseController { private static final int LIMIT = 500; @@ -73,6 +74,7 @@ public JobManagerController(JobManagerService service) { public JobManager create(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody JobManager entity) { + this.requireGraphSpaceWrite(graphSpace); synchronized (this.service) { Ex.check(!StringUtils.isEmpty(entity.getJobName()), "common.param.cannot-be-null-or-empty", "job_name"); @@ -111,6 +113,7 @@ public JobManager create(@PathVariable("graphspace") String graphSpace, public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("id") int id) { + this.requireGraphSpaceWrite(graphSpace); this.service.deleteJob(graphSpace, graph, id); } @@ -155,6 +158,7 @@ public JobManager update(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("id") int id, @RequestBody JobManager newEntity) { + this.requireGraphSpaceWrite(graphSpace); Ex.check(!StringUtils.isEmpty(newEntity.getJobName()), "common.param.cannot-be-null-or-empty", "job_name"); Ex.check(newEntity.getJobName().length() <= 48, diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java index 66f534c18..5b45e7800 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/load/LoadTaskController.java @@ -113,6 +113,7 @@ public LoadTask create(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @RequestBody LoadTask entity) { + this.requireGraphSpaceWrite(graphSpace); JobManager jobEntity = this.jobService.get(graphSpace, graph, jobId); Ex.check(jobEntity != null, "job-manager.not-exist.id", jobId); Ex.check(jobEntity.getJobStatus() == JobStatus.SETTING, @@ -133,6 +134,7 @@ public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @PathVariable("id") int id) { + this.requireGraphSpaceWrite(graphSpace); LoadTask task = this.service.get(graphSpace, graph, jobId, id); if (task == null) { throw new ExternalException("load.task.not-exist.id", id); @@ -152,6 +154,7 @@ public List start(@PathVariable("graphspace") String graphSpace, @PathVariable("jobId") int jobId, @RequestParam("file_mapping_ids") List fileIds) { + this.requireGraphSpaceWrite(graphSpace); GraphConnection connection = new GraphConnection(); connection.setCluster(config.get(HubbleOptions.PD_CLUSTER)); @@ -205,6 +208,7 @@ public LoadTask pause(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @RequestParam("task_id") int taskId) { + this.requireGraphSpaceWrite(graphSpace); JobManager jobEntity = this.jobService.get(graphSpace, graph, jobId); Ex.check(jobEntity != null, "job-manager.not-exist.id", jobId); Ex.check(this.service.get(graphSpace, graph, jobId, taskId) != null, @@ -225,6 +229,7 @@ public LoadTask resume(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @RequestParam("task_id") int taskId) { + this.requireGraphSpaceWrite(graphSpace); JobManager jobEntity = this.jobService.get(graphSpace, graph, jobId); Ex.check(jobEntity != null, "job-manager.not-exist.id", jobId); Ex.check(this.service.get(graphSpace, graph, jobId, taskId) != null, @@ -245,6 +250,7 @@ public LoadTask stop(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @RequestParam("task_id") int taskId) { + this.requireGraphSpaceWrite(graphSpace); JobManager jobEntity = this.jobService.get(graphSpace, graph, jobId); Ex.check(jobEntity != null, "job-manager.not-exist.id", jobId); Ex.check(this.service.get(graphSpace, graph, jobId, taskId) != null, @@ -265,6 +271,7 @@ public LoadTask retry(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @RequestParam("task_id") int taskId) { + this.requireGraphSpaceWrite(graphSpace); JobManager jobEntity = this.jobService.get(graphSpace, graph, jobId); Ex.check(jobEntity != null, "job-manager.not-exist.id", jobId); Ex.check(this.service.get(graphSpace, graph, jobId, taskId) != null, diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/OperationsController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/OperationsController.java index a8eb01420..ab8371bea 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/OperationsController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/op/OperationsController.java @@ -100,6 +100,9 @@ private Set currentCapabilities() { } private Set currentCapabilities(HugeClient client) { + if (this.authMode != null && this.authMode.anonymous()) { + return OperationsCapabilityService.forLevel("ADMIN"); + } String level = this.userService.userLevel(client, this.getUser()); return OperationsCapabilityService.forLevel(level); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/ExecuteHistoryController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/ExecuteHistoryController.java index b7f1fe24c..945297a38 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/ExecuteHistoryController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/ExecuteHistoryController.java @@ -65,14 +65,19 @@ public ExecuteHistory get(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("id") int id) { HugeClient client = this.authClient(graphSpace, graph); - return this.service.get(client, id); + ExecuteHistory history = this.service.get(client, id); + if (history == null) { + throw new ExternalException("execute-history.not-exist.id", id); + } + return history; } @DeleteMapping("{id}") public ExecuteHistory delete(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("id") int id) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.requireGraphSpaceWrite(graphSpace); + client.assignGraph(graphSpace, graph); ExecuteHistory oldEntity = this.service.get(client, id); if (oldEntity == null) { throw new ExternalException("execute-history.not-exist.id", id); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/GremlinCollectionController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/GremlinCollectionController.java index 8f66c8590..922b131c1 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/GremlinCollectionController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/query/GremlinCollectionController.java @@ -105,14 +105,18 @@ public IPage list(@PathVariable("graphspace") String graphSpa } @GetMapping("{id}") - public GremlinCollection get(@PathVariable("id") int id) { - return this.service.get(id); + public GremlinCollection get( + @PathVariable("graphspace") String graphSpace, + @PathVariable("graph") String graph, + @PathVariable("id") int id) { + return this.service.get(graphSpace, graph, id); } @PostMapping public GremlinCollection create(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody GremlinCollection newEntity) { + this.requireGraphSpaceWrite(graphSpace); this.checkParamsValid(newEntity, true); newEntity.setGraphSpace(graphSpace); newEntity.setGraph(graph); @@ -128,29 +132,39 @@ public GremlinCollection create(@PathVariable("graphspace") String graphSpace, } @PutMapping("{id}") - public GremlinCollection update(@PathVariable("id") int id, + public GremlinCollection update( + @PathVariable("graphspace") String graphSpace, + @PathVariable("graph") String graph, + @PathVariable("id") int id, @RequestBody GremlinCollection newEntity) { + this.requireGraphSpaceWrite(graphSpace); this.checkIdSameAsBody(id, newEntity); this.checkParamsValid(newEntity, false); - GremlinCollection oldEntity = this.service.get(id); + GremlinCollection oldEntity = this.service.get(graphSpace, graph, id); if (oldEntity == null) { throw new ExternalException("gremlin-collection.not-exist.id", id); } GremlinCollection entity = this.mergeEntity(oldEntity, newEntity); + entity.setGraphSpace(graphSpace); + entity.setGraph(graph); this.checkEntityUnique(entity, false); - this.service.update(entity); + this.service.update(graphSpace, graph, entity); return entity; } @DeleteMapping("{id}") - public GremlinCollection delete(@PathVariable("id") int id) { - GremlinCollection oldEntity = this.service.get(id); + public GremlinCollection delete( + @PathVariable("graphspace") String graphSpace, + @PathVariable("graph") String graph, + @PathVariable("id") int id) { + this.requireGraphSpaceWrite(graphSpace); + GremlinCollection oldEntity = this.service.get(graphSpace, graph, id); if (oldEntity == null) { throw new ExternalException("gremlin-collection.not-exist.id", id); } - this.service.remove(id); + this.service.remove(graphSpace, graph, id); return oldEntity; } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/saas/SaasGraphViewController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/saas/SaasGraphViewController.java index fd34bef72..b06c6ac9b 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/saas/SaasGraphViewController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/saas/SaasGraphViewController.java @@ -84,7 +84,7 @@ public GremlinResult execute(@PathVariable("graphspace") String graphSpace, StopWatch timer = StopWatch.createStarted(); try { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); GremlinResult result = this.queryService.executeGremlinQuery(client, query.convert2GremlinQuery()); @@ -124,7 +124,7 @@ public Map executeAsyncTask( Map result = new HashMap<>(3); try { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); asyncId = this.queryService.executeGremlinAsyncTask(client, query.convert2GremlinQuery()); status = ExecuteStatus.ASYNC_TASK_SUCCESS; diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/schema/SchemaController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/schema/SchemaController.java index 58009368b..d209e8733 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/schema/SchemaController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/schema/SchemaController.java @@ -91,7 +91,7 @@ public Object schemaGroovy(@PathVariable("graphspace") String graphSpace, public Object addSchemaGroovy(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @RequestBody SchemaGroovy schemaGroovy) { - HugeClient client = this.authClient(graphSpace, graph); + HugeClient client = this.authGremlinClient(graphSpace, graph); String content = schemaGroovy.getSchemaGroovy(); log.info("Add schema groovy: {}", content); checkSchemaGroovy(content); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java index b8c3bc712..60fb08a8a 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.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; @@ -81,8 +80,15 @@ public Object list() { Collections.singletonList("DEFAULT")); } - List graphSpaces = - this.graphSpaceService.listAll(this.authClient(null, null)); + HugeClient client = this.authClient(null, null); + List graphSpaces; + if (this.authMode != null && this.authMode.anonymous()) { + graphSpaces = this.graphSpaceService.listAnonymous(client); + } else if (this.userService.isSuperAdmin(client)) { + graphSpaces = this.graphSpaceService.listAll(client); + } else { + graphSpaces = this.graphSpaceService.listAccessible(client); + } return ImmutableMap.of("graphspaces", graphSpaces); } @@ -101,6 +107,14 @@ public Object queryPage(@RequestParam(name = "query", required = false, return ImmutableMap.of("records", Collections.emptyList(), "total", 0); } + if (this.authMode != null && this.authMode.anonymous()) { + HugeClient client = this.authClient(null, null); + return all ? + this.graphSpaceService.queryAnonymousGs(client, query, + createTime) : + this.graphSpaceService.queryAnonymousGsPage( + client, query, createTime, pageNo, pageSize); + } if (all) { HugeClient client = this.authClient(null, null); return this.userService.isSuperAdmin(client) ? @@ -113,8 +127,8 @@ public Object queryPage(@RequestParam(name = "query", required = false, 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") @@ -122,8 +136,17 @@ public Object isAuth(@PathVariable("graphspace") String graphSpace) { if (!isPdEnabled()) { return ImmutableMap.of("auth", false); } - boolean isAuth = graphSpaceService.isAuth(this.authClient(null, null), - graphSpace); + HugeClient client = this.authClient(null, null); + boolean isAuth; + if (this.authMode != null && this.authMode.anonymous()) { + isAuth = this.graphSpaceService.isAuthForAnonymous(client, + graphSpace); + } else if (this.userService.isSuperAdmin(client)) { + isAuth = this.graphSpaceService.isAuth(client, graphSpace); + } else { + isAuth = this.graphSpaceService.isAuthForAccessible(client, + graphSpace); + } return ImmutableMap.of("auth", isAuth); } @@ -137,9 +160,15 @@ public Object get(@PathVariable("graphspace") String graphspace) { return this.graphSpaceService.toView(stub); } HugeClient client = this.authClient(null, null); - // Get GraphSpace Info - return graphSpaceService.toView( - graphSpaceService.getWithAdmins(client, graphspace)); + if (this.authMode != null && this.authMode.anonymous()) { + return this.graphSpaceService.getAnonymous(client, graphspace); + } + GraphSpaceEntity entity = this.userService.isSuperAdmin(client) ? + graphSpaceService.getWithAdmins(client, + graphspace) : + graphSpaceService.getAccessibleWithAdmins( + client, graphspace); + return graphSpaceService.toView(entity); } @PostMapping 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 2e887a342..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.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; @@ -94,7 +97,7 @@ public Object create(@PathVariable("graphspace") String graphSpace, @RequestBody SchemaTemplate schemaTemplate) { E.checkArgument(isPdEnabled(), "Schema template is not supported in standalone mode"); - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireGraphSpaceWrite(graphSpace); return schemaTemplateService.create(client, schemaTemplate); } @@ -102,7 +105,8 @@ public Object create(@PathVariable("graphspace") String graphSpace, @DeleteMapping("{name}") public void delete(@PathVariable("graphspace") String graphSpace, @PathVariable("name") String name) { - HugeClient client = this.authClient(graphSpace, null); + HugeClient client = this.requireTemplateOwnerOrManager(graphSpace, + name); schemaTemplateService.delete(client, name); } @@ -110,8 +114,28 @@ public void delete(@PathVariable("graphspace") String graphSpace, public Object update(@PathVariable("graphspace") String graphSpace, @PathVariable("name") String name, @RequestBody SchemaTemplate schemaTemplate) { - HugeClient client = this.authClient(graphSpace, null); + 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/controller/space/VermeerController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/VermeerController.java index 27508d836..4206a8fec 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/VermeerController.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/VermeerController.java @@ -67,7 +67,7 @@ public void load(@RequestBody JsonLoad body) { String graphspace = body.graphspace; String graph = body.graph; String vGraph = vermeerService.convert2VG(graphspace, graph); - HugeClient client = this.authClient(null, null); + HugeClient client = this.authClient(graphspace, graph); Map graphInfo = HubbleUtil.uncheckedCast( client.vermeer().getGraphInfoByName(vGraph).get("graph")); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/RoleEntity.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/RoleEntity.java index 5da056121..89c6b4e2f 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/RoleEntity.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/RoleEntity.java @@ -19,7 +19,6 @@ package org.apache.hugegraph.entity.auth; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -28,7 +27,6 @@ @Data @NoArgsConstructor -@AllArgsConstructor @Builder public class RoleEntity implements Identifiable { @@ -37,4 +35,17 @@ public class RoleEntity implements Identifiable { @JsonProperty("role_name") private String name; + + @JsonProperty("permission_preset") + private String permissionPreset; + + public RoleEntity(String id, String name) { + this(id, name, null); + } + + public RoleEntity(String id, String name, String permissionPreset) { + this.id = id; + this.name = name; + this.permissionPreset = permissionPreset; + } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java index 9e3712819..f7953c5d3 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/entity/auth/UserEntity.java @@ -30,6 +30,7 @@ import java.util.Date; import java.util.List; +import java.util.Map; @Data @NoArgsConstructor @@ -75,6 +76,12 @@ public class UserEntity implements Identifiable { @JsonProperty("resSpaces") protected List resSpaces; + @JsonProperty("permission_preset") + private String permissionPreset; + + @JsonProperty("graphspace_permissions") + private List> graphspacePermissions; + @JsonProperty("spacenum") protected Integer spacenum; diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/CustomInterceptor.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/CustomInterceptor.java index b48d9cdbb..ba7abc40c 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/CustomInterceptor.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/CustomInterceptor.java @@ -19,6 +19,8 @@ package org.apache.hugegraph.handler; import java.util.regex.Pattern; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -26,6 +28,8 @@ //import org.apache.hugegraph.license.LicenseVerifier; // TODO C Remove Licence import org.apache.hugegraph.service.HugeClientPoolService; +import org.apache.hugegraph.service.auth.AuthModeService; +import org.apache.hugegraph.service.space.GraphSpaceService; //import org.apache.hugegraph.service.license.LicenseService;// TODO C Remove Licence import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -33,8 +37,10 @@ import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import org.apache.hugegraph.common.Constant; +import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.util.PageUtil; import lombok.extern.log4j.Log4j2; @@ -47,6 +53,12 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { //private LicenseService licenseService;// TODO C Remove Licence @Autowired protected HugeClientPoolService hugeClientPoolService; + @Autowired + protected AuthModeService authMode; + @Autowired + protected HugeConfig config; + @Autowired + protected GraphSpaceService graphSpaceService; private static final Pattern CHECK_API_PATTERN = Pattern.compile(".*/graph-connections/\\d+/.+"); @@ -58,6 +70,9 @@ public boolean preHandle(HttpServletRequest request, validatePage(request, "page_no", false); validatePage(request, "page_size", true); String url = request.getRequestURI(); + if (url.endsWith("/config")) { + return true; + } if (!CHECK_API_PATTERN.matcher(url).matches()) { setHugeClientToRequest(request); return true; @@ -119,28 +134,53 @@ public void setHugeClientToRequest(HttpServletRequest request) { if (this.isLogoutRequest(uri)) { return; } - if (!this.hasAuthSession(request)) { + if (this.authMode != null && this.authMode.anonymous() && + uri.endsWith("/auth/status")) { return; } - String token = - (String) request.getSession().getAttribute(Constant.TOKEN_KEY); - String [] res = uri.split("/"); - String graphSpace = null; - String graph = null; - for (int i = 0; i < res.length; i++) { - if ("graphspaces".equals(res[i]) && i < res.length - 1) { - graphSpace = res[i + 1]; - } - if ("graphs".equals(res[i]) && i < res.length - 1) { - graph = res[i + 1]; - } + String[] scope = this.requestScope(uri); + String graphSpace = scope[0]; + String graph = scope[1]; + boolean anonymous = this.authMode != null && + this.authMode.anonymous(); + if (anonymous) { + client = unauthClient(graphSpace, graph); + } else if (!this.hasAuthSession(request)) { + return; + } else { + String token = + (String) request.getSession().getAttribute(Constant.TOKEN_KEY); + client = this.authClient(graphSpace, graph, token); } - client = this.authClient(graphSpace, graph, token); + this.requireGraphSpaceAccess(client, graphSpace, anonymous); + request.setAttribute(Constant.GRAPHSPACE_ACCESS_KEY, graphSpace); } request.setAttribute("hugeClient", client); } + protected void requireGraphSpaceAccess(HugeClient client, + String graphSpace, + boolean anonymous) { + if (graphSpace == null || this.config == null || + !this.config.get(HubbleOptions.PD_ENABLED)) { + return; + } + try { + if (anonymous) { + this.graphSpaceService.requirePublicSpace(client, graphSpace); + } else { + this.graphSpaceService.requireAccessibleSpace(client, + graphSpace); + } + } catch (RuntimeException e) { + if (client != null) { + client.close(); + } + throw e; + } + } + private boolean isLoginRequest(String uri) { return (Constant.API_VERSION + "auth/login").equals(uri) || uri.endsWith("/auth/login"); @@ -174,4 +214,43 @@ protected HugeClient authClient(String graphSpace, String graph, protected HugeClient unauthClient() { return this.hugeClientPoolService.createUnauthClient(); } + + protected HugeClient unauthClient(String graphSpace, String graph) { + return this.hugeClientPoolService.createUnauthClient(graphSpace, graph); + } + + private String[] requestScope(String uri) { + String graphSpace = null; + String graph = null; + String[] parts = uri.split("/"); + for (int i = 0; i < parts.length; i++) { + if ("graphspaces".equals(parts[i]) && i < parts.length - 1) { + String candidate = parts[i + 1]; + boolean collectionAction = i + 1 == parts.length - 1 && + ("list".equals(candidate) || + "builtin".equals(candidate)); + if (!collectionAction) { + graphSpace = decodeSegment(candidate); + } + } + if ("graphs".equals(parts[i]) && i < parts.length - 1) { + String candidate = parts[i + 1]; + boolean collectionAction = i + 1 == parts.length - 1 && + ("list".equals(candidate) || + "default".equals(candidate)); + if (!collectionAction) { + graph = decodeSegment(candidate); + } + } + } + return new String[]{graphSpace, graph}; + } + + private static String decodeSegment(String segment) { + try { + return URLDecoder.decode(segment, StandardCharsets.UTF_8.name()); + } catch (java.io.UnsupportedEncodingException ignored) { + return segment; + } + } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java index b0c1dd1ed..74b62e38b 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/handler/LoginInterceptor.java @@ -20,6 +20,10 @@ import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.exception.UnauthorizedException; +import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.service.auth.AuthModeService; +import org.springframework.http.HttpStatus; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.StringUtils; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; @@ -28,6 +32,9 @@ public class LoginInterceptor extends HandlerInterceptorAdapter { + @Autowired + private AuthModeService authMode; + @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, @@ -35,6 +42,13 @@ public boolean preHandle(HttpServletRequest request, if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } + if (this.authMode != null && this.authMode.anonymous()) { + if (isAnonymousAuthManagement(request.getRequestURI())) { + throw new ExternalException(HttpStatus.FORBIDDEN.value(), + "Authentication is disabled"); + } + return true; + } if (!this.hasTextSessionAttribute(request, Constant.TOKEN_KEY) || !this.hasTextSessionAttribute(request, Constant.USERNAME_KEY)) { @@ -49,4 +63,33 @@ private boolean hasTextSessionAttribute(HttpServletRequest request, Object value = request.getSession().getAttribute(key); return value instanceof String && StringUtils.hasText((String) value); } + + private static boolean isAnonymousAuthManagement(String uri) { + 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; + } + + String graphSpaces = Constant.API_VERSION + "graphspaces/"; + if (!apiPath.startsWith(graphSpaces)) { + return false; + } + int graphSpaceEnd = apiPath.indexOf('/', graphSpaces.length()); + if (graphSpaceEnd < 0) { + return false; + } + String scopedAuth = apiPath.substring(0, graphSpaceEnd) + "/auth"; + return apiPath.startsWith(scopedAuth + "/"); + } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java index c04132c32..56b544dc3 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/options/HubbleOptions.java @@ -279,6 +279,14 @@ public static synchronized HubbleOptions instance() { true ); + public static final ConfigOption AUTH_ENABLED = + new ConfigOption<>("auth.enabled", + "Whether Hubble requires a user session. Set false when " + + "the connected HugeGraph Server runs in anonymous mode.", + null, + true + ); + public static final ConfigOption SERVER_URL = new ConfigOption<>( "server.direct_url", diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java index ccd3b7033..167f7f5c0 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/HugeClientPoolService.java @@ -91,6 +91,10 @@ public HugeClient createUnauthClient() { return getOrCreate(null, null, null, null); } + public HugeClient createUnauthClient(String graphSpace, String graph) { + return getOrCreate(null, graphSpace, graph, null); + } + public HugeClient createTempTokenClient(String token) { return getOrCreate(null, null, null, token); } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthContextService.java index dca1c4beb..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 @@ -38,6 +38,7 @@ import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.exception.ForbiddenException; import org.apache.hugegraph.exception.InternalException; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.op.OperationsCapabilityService; @@ -51,6 +52,8 @@ public class AuthContextService { public static final String ACCOUNT_SELF_MANAGE = "account_self_manage"; public static final String ACCOUNTS_MANAGE = "accounts_manage"; + public static final String ACCOUNT_PERMISSION_PRESETS = + "account_permission_presets"; public static final String GRAPHSPACES_READ = "graphspaces_read"; public static final String GRAPHSPACES_MANAGE = "graphspaces_manage"; public static final String GRAPHSPACE_MEMBERS_MANAGE = @@ -66,6 +69,8 @@ public class AuthContextService { private static final char[] HEX = "0123456789abcdef".toCharArray(); private static final Set SELF_ACTIONS = set( "read", "update", "change_password"); + private static final Set LEGACY_SELF_ACTIONS = set( + "read", "change_password"); private static final Set CRUD_ACTIONS = set( "read", "create", "update", "delete"); private static final Set MEMBER_ACTIONS = set( @@ -87,12 +92,17 @@ public AuthContextService(HugeConfig config, UserService users) { } public Map context(HugeClient client, String username) { + if (Boolean.FALSE.equals( + this.config.get(HubbleOptions.AUTH_ENABLED))) { + return anonymousContext(this.config.get(HubbleOptions.PD_ENABLED)); + } boolean pdEnabled = this.config.get(HubbleOptions.PD_ENABLED); String mode = pdEnabled ? "PD" : "NON_PD"; String role; + UserEntity user = null; List adminGraphSpaces = Collections.emptyList(); if (pdEnabled) { - UserEntity user = this.users.getpersonal(client, username); + user = this.users.getpersonal(client, username); adminGraphSpaces = sorted(user.getAdminSpaces()); if (user.isSuperadmin()) { role = SUPERADMIN; @@ -106,10 +116,22 @@ public Map context(HugeClient client, String username) { role = "ADMIN".equals(serverRole) ? SUPERADMIN : USER; } - Set capabilities = this.capabilities(pdEnabled, role); - Map> actions = this.actions(pdEnabled, role); + boolean permissionPresets = pdEnabled && + client.supportsDefaultRole(); + boolean profileUpdate = + client.supportsPersonalProfileUpdate(); + List writeGraphSpaces = pdEnabled ? + writeGraphSpaces(user, + permissionPresets) : + Collections.emptyList(); + Set capabilities = this.capabilities(pdEnabled, role, + permissionPresets); + Map> actions = this.actions(pdEnabled, role, + permissionPresets, + profileUpdate); Map scopes = this.scopes(pdEnabled, role, - adminGraphSpaces); + adminGraphSpaces, + writeGraphSpaces); String version = version(mode, username, role, capabilities, actions, scopes); @@ -125,22 +147,78 @@ public Map context(HugeClient client, String username) { return Collections.unmodifiableMap(context); } - private Set capabilities(boolean pdEnabled, String role) { + public void requireGraphSpaceWrite(HugeClient client, String username, + String graphSpace) { + if (Boolean.FALSE.equals( + this.config.get(HubbleOptions.AUTH_ENABLED)) || + !this.config.get(HubbleOptions.PD_ENABLED) || + !client.supportsDefaultRole()) { + return; + } + + UserEntity user = this.users.getpersonal(client, username); + if (user.isSuperadmin() || + contains(user.getAdminSpaces(), graphSpace) || + writeGraphSpaces(user, true).contains(graphSpace)) { + return; + } + throw new ForbiddenException( + "Permission denied: write graphspace resources"); + } + + private static Map anonymousContext(boolean pdEnabled) { + Set capabilities = new LinkedHashSet<>(); + capabilities.add(GRAPH_RESOURCES_ACCESS); + if (pdEnabled) { + capabilities.add(GRAPHSPACES_READ); + } + capabilities.addAll(OperationsCapabilityService.forLevel("ADMIN")); + + Map context = new LinkedHashMap<>(); + context.put("schema_version", SCHEMA_VERSION); + context.put("context_version", + pdEnabled ? "anonymous-v2-pd" : "anonymous-v2-non-pd"); + context.put("mode", "NON_AUTH"); + context.put("username", null); + context.put("role", "ANONYMOUS"); + Map> actions = new LinkedHashMap<>(); + actions.put("graphspaces", pdEnabled ? + Collections.singleton("read") : Collections.emptySet()); + actions.put("operations", OPERATIONS_ACTIONS); + context.put("capabilities", + Collections.unmodifiableSet(capabilities)); + context.put("actions", actions); + Map scopes = new LinkedHashMap<>(); + scopes.put("all_graphspaces", pdEnabled); + scopes.put("admin_graphspaces", Collections.emptyList()); + scopes.put("write_graphspaces", Collections.emptyList()); + scopes.put("graph_resources", "SERVER_ANONYMOUS"); + context.put("scopes", scopes); + return Collections.unmodifiableMap(context); + } + + private Set capabilities(boolean pdEnabled, String role, + boolean permissionPresets) { Set capabilities = new LinkedHashSet<>(); capabilities.add(ACCOUNT_SELF_MANAGE); capabilities.add(GRAPH_RESOURCES_ACCESS); if (pdEnabled) { capabilities.add(GRAPHSPACES_READ); } + if (permissionPresets) { + capabilities.add(ACCOUNT_PERMISSION_PRESETS); + } if (SUPERADMIN.equals(role)) { capabilities.add(ACCOUNTS_MANAGE); if (pdEnabled) { capabilities.add(GRAPHSPACES_MANAGE); } } - if (pdEnabled && (SUPERADMIN.equals(role) || - SPACEADMIN.equals(role))) { + if (pdEnabled && permissionPresets && + (SUPERADMIN.equals(role) || SPACEADMIN.equals(role))) { capabilities.add(GRAPHSPACE_MEMBERS_MANAGE); + } + if (pdEnabled && SUPERADMIN.equals(role)) { capabilities.add(GRAPHSPACE_ROLES_MANAGE); capabilities.add(GRAPHSPACE_AUTHORIZATIONS_MANAGE); } @@ -150,18 +228,22 @@ private Set capabilities(boolean pdEnabled, String role) { return Collections.unmodifiableSet(capabilities); } - private Map> actions(boolean pdEnabled, String role) { + private Map> actions(boolean pdEnabled, String role, + boolean permissionPresets, + boolean profileUpdate) { Map> actions = new LinkedHashMap<>(); boolean superAdmin = SUPERADMIN.equals(role); - boolean spaceManager = pdEnabled && + 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()); actions.put("members", spaceManager ? MEMBER_ACTIONS : emptySet()); - actions.put("roles", spaceManager ? CRUD_ACTIONS : emptySet()); - actions.put("authorizations", spaceManager ? + actions.put("roles", permissionPresets && superAdmin ? + CRUD_ACTIONS : emptySet()); + actions.put("authorizations", permissionPresets && superAdmin ? AUTHORIZATION_ACTIONS : emptySet()); actions.put("operations", superAdmin ? OPERATIONS_ACTIONS : emptySet()); @@ -170,15 +252,50 @@ private Map> actions(boolean pdEnabled, String role) { } private Map scopes(boolean pdEnabled, String role, - List adminGraphSpaces) { + List adminGraphSpaces, + List writeGraphSpaces) { Map scopes = new LinkedHashMap<>(); scopes.put("all_graphspaces", pdEnabled && SUPERADMIN.equals(role)); scopes.put("admin_graphspaces", adminGraphSpaces); + scopes.put("write_graphspaces", writeGraphSpaces); scopes.put("graph_resources", "SERVER_AUTHORIZED"); return Collections.unmodifiableMap(scopes); } + private static List writeGraphSpaces(UserEntity user, + boolean permissionPresets) { + if (user.isSuperadmin()) { + return Collections.emptyList(); + } + if (!permissionPresets) { + return sorted(user.getResSpaces()); + } + + Set values = new TreeSet<>(); + if (user.getGraphspacePermissions() != null) { + for (Map permission : + user.getGraphspacePermissions()) { + String preset = permission.get("permission_preset"); + if ("GS_READ_WRITE".equals(preset) || + "GS_ADMIN".equals(preset)) { + String graphSpace = permission.get("graphspace"); + if (graphSpace != null) { + values.add(graphSpace); + } + } + } + } + if (user.getAdminSpaces() != null) { + for (String graphSpace : user.getAdminSpaces()) { + if (graphSpace != null) { + values.add(graphSpace); + } + } + } + return Collections.unmodifiableList(new ArrayList<>(values)); + } + private static List sorted(Collection values) { if (values == null || values.isEmpty()) { return Collections.emptyList(); @@ -187,6 +304,10 @@ private static List sorted(Collection values) { new TreeSet<>(values))); } + private static boolean contains(Collection values, String value) { + return values != null && values.contains(value); + } + private static Set set(String... values) { return Collections.unmodifiableSet(new LinkedHashSet<>( Arrays.asList(values))); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthModeService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthModeService.java new file mode 100644 index 000000000..f4f8c8230 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/AuthModeService.java @@ -0,0 +1,47 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.service.auth; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.options.HubbleOptions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * Single boundary for Hubble authentication mode. Business controllers should + * not infer mode from sessions or PD settings. + */ +@Service +public final class AuthModeService { + + private final HugeConfig config; + + @Autowired + public AuthModeService(HugeConfig config) { + this.config = config; + } + + public boolean enabled() { + return !Boolean.FALSE.equals(this.config.get(HubbleOptions.AUTH_ENABLED)); + } + + public boolean anonymous() { + return !this.enabled(); + } +} diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/GraphSpaceUserService.java index cfdd501cf..93445da82 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 @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -32,6 +33,7 @@ import org.apache.hugegraph.entity.auth.BelongEntity; import org.apache.hugegraph.entity.auth.RoleEntity; import org.apache.hugegraph.entity.auth.UserView; +import org.apache.hugegraph.exception.ParameterizedException; import org.apache.hugegraph.structure.auth.User; import org.apache.hugegraph.util.E; import org.apache.hugegraph.util.PageUtil; @@ -46,7 +48,7 @@ public class GraphSpaceUserService extends AuthService { private BelongService belongService; public List listUsers(HugeClient client, String graphSpace) { - List users = new ArrayList<>(); + Map users = new java.util.LinkedHashMap<>(); List belongs = this.belongService.list( client, graphSpace, null, null); @@ -63,9 +65,22 @@ public List listUsers(HugeClient client, String graphSpace) { user.addRole(new RoleEntity(belong.getRoleId(), belong.getRoleName())); }); - users.add(user); + users.put(userId, user); }); - return users; + client.auth().listSpaceMember(graphSpace).forEach(username -> { + User account = client.findUserByName(username); + if (account == null) { + return; + } + String userId = account.id().toString(); + UserView user = users.computeIfAbsent(userId, + id -> new UserView(id, username, new ArrayList<>())); + if (client.supportsDefaultRole()) { + this.addDefaultRole(client, graphSpace, user, username, "observer"); + this.addDefaultRole(client, graphSpace, user, username, "analyst"); + } + }); + return new ArrayList<>(users.values()); } public UserView getUser(HugeClient client, String graphSpace, @@ -80,6 +95,19 @@ public UserView getUser(HugeClient client, String graphSpace, user.addRole(new RoleEntity(belong.getRoleId(), belong.getRoleName())); }); + User account = client.auth().getUser(userId); + if (account != null) { + if (user.getId() == null) { + user.setId(account.id().toString()); + user.setName(account.name()); + } + if (client.supportsDefaultRole()) { + this.addDefaultRole(client, graphSpace, user, + account.name(), "observer"); + this.addDefaultRole(client, graphSpace, user, + account.name(), "analyst"); + } + } return user; } @@ -95,6 +123,7 @@ public IPage queryPage(HugeClient client, String graphSpace, public UserView createOrUpdate(HugeClient client, String graphSpace, UserView userView) { + requireLegacyRoleAssignments(client); E.checkNotNull(userView.getId(), "User Id Not Null"); E.checkArgument(userView.getRoles() != null && !userView.getRoles().isEmpty(), @@ -135,17 +164,374 @@ public UserView createOrUpdate(HugeClient client, String graphSpace, return this.getUser(client, graphSpace, userView.getId()); } + public void applyPermissionPresets(HugeClient client, String username, + List> permissions, + String preset) { + this.applyPermissionPresets(client, username, permissions, preset, + true); + } + + public void applyPermissionPresetsForNewAccount( + HugeClient client, String username, + List> permissions, String preset) { + this.applyPermissionPresets(client, username, permissions, preset, + false); + } + + private void applyPermissionPresets(HugeClient client, String username, + List> permissions, + String preset, + boolean reconcileUnrequestedSpaces) { + if (preset == null || "SUPER_ADMIN".equals(preset)) { + return; + } + requirePermissionPresets(client); + User account = client.findUserByName(username); + if (account == null) { + return; + } + Map desired = new java.util.LinkedHashMap<>(); + List> requested = permissions == null ? new ArrayList<>() : permissions; + for (Map permission : requested) { + String graphSpace = permission.get("graphspace"); + String permissionPreset = permission.get("permission_preset"); + if (graphSpace != null) { + desired.put(graphSpace, permissionPreset); + } + } + if (!reconcileUnrequestedSpaces) { + desired.forEach((graphSpace, desiredPreset) -> + this.applySpacePreset(client, graphSpace, + account.id().toString(), + account.name(), + desiredPreset)); + return; + } + List graphSpaces = client.graphSpace().listGraphSpace(); + Map 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; + } + } + + public void validatePermissionPresets( + HugeClient client, List> permissions, + String preset) { + if (preset == null || "SUPER_ADMIN".equals(preset)) { + return; + } + requirePermissionPresets(client); + requirePermissionPreset(preset); + if (permissions == null || permissions.isEmpty()) { + throw new ParameterizedException( + "auth.permission-preset.graphspace-required"); + } + Set graphSpaces = + new java.util.HashSet<>(client.graphSpace().listGraphSpace()); + for (Map permission : + permissions == null ? new ArrayList>() : 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)) { + throw new ParameterizedException( + "auth.permission-preset.graphspace-not-found", + 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 username, String preset) { + requirePermissionPreset(preset); + requirePermissionPresets(client); + 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'", username); + boolean wasMember = + client.auth().listSpaceMember(graphSpace).contains(username); + String resolvedUserId = userId; + SpacePresetState previous = null; + try { + if (!wasMember) { + client.auth().addSpaceMember(username, graphSpace); + } + User account = userId == null ? + client.findUserByName(username) : + client.auth().getUser(userId); + E.checkNotNull(account, "User"); + 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())); + if (previous.analyst) { + client.graphSpace().deleteDefaultRole( + graphSpace, username, "analyst"); + } + if (previous.observer) { + client.graphSpace().deleteDefaultRole( + graphSpace, username, "observer"); + } + if ("GS_ADMIN".equals(preset)) { + if (!previous.admin) { + client.auth().addSpaceAdmin(username, graphSpace); + } + this.setDefaultRole(client, graphSpace, username, "analyst"); + return; + } + if (previous.admin) { + client.auth().delSpaceAdmin(username, graphSpace); + } + String role = "GS_READ_ONLY".equals(preset) ? + "observer" : "analyst"; + this.setDefaultRole(client, graphSpace, username, role); + } catch (RuntimeException e) { + if (previous == null) { + this.rollbackNewMember(client, graphSpace, username, + wasMember, e); + } else { + this.restorePresetState(client, graphSpace, resolvedUserId, username, previous, e); + } + throw e; + } + } + + public void removeSpacePreset(HugeClient client, String graphSpace, + String userId) { + this.unauthUser(client, graphSpace, userId); + } + + private static void requirePermissionPresets(HugeClient client) { + if (!client.supportsDefaultRole()) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); + } + } + + private SpacePresetState capturePresetState(HugeClient client, + String graphSpace, + String userId, + String username, + boolean member) { + List customRoles = this.belongService.list( + client, graphSpace, null, userId); + boolean analyst = client.graphSpace().checkDefaultRole( + graphSpace, username, "analyst"); + boolean observer = client.graphSpace().checkDefaultRole( + graphSpace, username, "observer"); + boolean admin = client.auth().listSpaceAdmin(graphSpace) + .contains(username); + return new SpacePresetState(customRoles, member, admin, + analyst, observer); + } + + private void restorePresetState(HugeClient client, String graphSpace, + 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 currentRoles = this.belongService.list( + client, graphSpace, null, userId).stream() + .map(BelongEntity::getRoleId) + .collect(Collectors.toSet()); + if (!currentRoles.contains(belong.getRoleId())) { + this.belongService.add(client, graphSpace, + belong.getRoleId(), userId); + } + }, graphSpace, userId, + "custom role " + belong.getRoleId(), failure); + }); + this.tryRestore(() -> this.restoreDefaultRole( + client, graphSpace, username, "analyst", previous.analyst), + graphSpace, userId, "analyst role", failure); + this.tryRestore(() -> this.restoreDefaultRole( + client, graphSpace, username, "observer", previous.observer), + graphSpace, userId, "observer role", failure); + this.tryRestore(() -> { + boolean current = client.auth().listSpaceAdmin(graphSpace) + .contains(username); + if (previous.admin && !current) { + client.auth().addSpaceAdmin(username, graphSpace); + } else if (!previous.admin && current) { + client.auth().delSpaceAdmin(username, graphSpace); + } + }, graphSpace, userId, "administrator", failure); + if (!previous.member) { + this.rollbackNewMember(client, graphSpace, username, false, failure); + } + } + + private void restoreDefaultRole(HugeClient client, String graphSpace, + String username, String role, + boolean expected) { + boolean current = client.graphSpace().checkDefaultRole( + graphSpace, username, role); + if (expected && !current) { + client.graphSpace().setDefaultRole(graphSpace, username, role); + } else if (!expected && current) { + client.graphSpace().deleteDefaultRole(graphSpace, username, role); + } + } + + private void rollbackNewMember(HugeClient client, String graphSpace, + String username, boolean expected, + RuntimeException failure) { + this.tryRestore(() -> { + boolean current = client.auth().listSpaceMember(graphSpace) + .contains(username); + if (expected && !current) { + client.auth().addSpaceMember(username, graphSpace); + } else if (!expected && current) { + client.auth().delSpaceMember(username, graphSpace); + } + }, graphSpace, username, "membership", failure); + } + + private void tryRestore(Runnable action, String graphSpace, + String userId, String state, + RuntimeException failure) { + try { + action.run(); + } catch (RuntimeException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + log.warn("Failed to restore GraphSpace {} for '{}' in '{}'", + state, userId, graphSpace, rollbackFailure); + } + } + + private static void requireLegacyRoleAssignments(HugeClient client) { + if (client.supportsDefaultRole()) { + throw new ParameterizedException( + "auth.permission-preset.required"); + } + } + + private static void requirePermissionPreset(String 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(); + } + + public boolean hasGraphSpaceAccess(HugeClient client, String graphSpace, + String username) { + if (!client.supportsDefaultRole()) { + return client.auth().listSpaceMember(graphSpace) + .contains(username); + } + return client.graphSpace().checkDefaultRole( + graphSpace, username, "analyst") || + client.graphSpace().checkDefaultRole( + graphSpace, username, "observer"); + } + + private void clearDefaultRoles(HugeClient client, String graphSpace, + String username) { + if (client.graphSpace().checkDefaultRole( + graphSpace, username, "analyst")) { + client.graphSpace().deleteDefaultRole(graphSpace, username, "analyst"); + } + if (client.graphSpace().checkDefaultRole( + graphSpace, username, "observer")) { + client.graphSpace().deleteDefaultRole( + graphSpace, username, "observer"); + } + } + + private void clearCustomRoles(HugeClient client, String graphSpace, + String userId) { + this.belongService.list(client, graphSpace, null, userId) + .forEach(belong -> this.belongService.deleteById(client, graphSpace, belong.getId())); + } + + private void setDefaultRole(HugeClient client, String graphSpace, + String username, String role) { + client.graphSpace().setDefaultRole(graphSpace, username, role); + } + + private void addDefaultRole(HugeClient client, String graphSpace, + UserView user, String username, String role) { + boolean assigned = client.graphSpace().checkDefaultRole( + graphSpace, username, role); + if (assigned) { + String preset = "observer".equals(role) ? + "GS_READ_ONLY" : "GS_READ_WRITE"; + user.addRole(new RoleEntity(role, role, preset)); + } + } + public void unauthUser(HugeClient client, String graphSpace, String userId) { User account = client.auth().getUser(userId); E.checkNotNull(account, "User"); List belongs = this.belongService.list( client, graphSpace, null, userId); - E.checkState(!belongs.isEmpty(), "The user: (%s) not exists", userId); belongs.forEach(belong -> { this.belongService.deleteById(client, graphSpace, belong.getId()); }); - client.auth().delSpaceMember(account.name(), graphSpace); + if (client.supportsDefaultRole()) { + this.clearDefaultRoles(client, graphSpace, account.name()); + } + if (client.auth().listSpaceAdmin(graphSpace).contains(account.name())) { + client.auth().delSpaceAdmin(account.name(), graphSpace); + } + if (client.auth().listSpaceMember(graphSpace).contains(account.name())) { + client.auth().delSpaceMember(account.name(), graphSpace); + } } public IPage querySpaceAdmins(HugeClient client, String graphSpace, @@ -163,8 +549,30 @@ private List getSpaceAdmins(HugeClient client, String graphSpace) { List spaceAdmins = client.auth().listSpaceAdmin(graphSpace); ArrayList users = new ArrayList<>(); for (String spaceAdmin : spaceAdmins) { - users.add(client.auth().getUser(spaceAdmin)); + User user = client.findUserByName(spaceAdmin); + if (user != null) { + users.add(user); + } } return users; } + + private static class SpacePresetState { + + private final List customRoles; + private final boolean member; + private final boolean admin; + private final boolean analyst; + private final boolean observer; + + private SpacePresetState(List customRoles, + boolean member, boolean admin, + boolean analyst, boolean observer) { + this.customRoles = customRoles; + this.member = member; + this.admin = admin; + this.analyst = analyst; + this.observer = observer; + } + } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java index 035ba359b..70b114e42 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/auth/UserService.java @@ -22,11 +22,14 @@ import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Comparator; +import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import com.baomidou.mybatisplus.core.metadata.IPage; import org.apache.hugegraph.common.Constant; import org.apache.hugegraph.common.Response; import org.apache.hugegraph.structure.auth.Login; @@ -40,7 +43,11 @@ 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; import org.apache.hugegraph.util.PageUtil; @@ -62,6 +69,8 @@ public class UserService extends AuthService { @Autowired private HugeConfig config; + @Autowired + private GraphSpaceUserService graphSpaceUserService; private boolean isPdEnabled() { return config.get(HubbleOptions.PD_ENABLED); @@ -91,13 +100,14 @@ public List listUsers(HugeClient hugeClient) { user.setSpacenum(countMap.get(user.getName())); user.setAdminSpaces(spaceMap.get(user.getName())); } + this.populatePermissionPresets(hugeClient, ues); } return ues; } public UserEntity getUser(HugeClient client, String name) { - return convert(client, client.auth().getUserByName(name)); + return convert(client, client.findUserByName(name)); } public Object queryPage(HugeClient hugeClient, String query, @@ -125,6 +135,9 @@ public Object queryPage(HugeClient hugeClient, String query, user.setAdminSpaces(spaceMap.get(user.getName())); user.setSuperadmin(isSuperAdmin(hugeClient, user.getId())); } + IPage page = PageUtil.page(results, pageNo, pageSize); + this.populatePermissionPresets(hugeClient, page.getRecords()); + return page; } else { for (UserEntity user : results) { user.setSuperadmin(isStandaloneAdmin(user.getName())); @@ -147,10 +160,14 @@ public UserEntity get(HugeClient hugeClient, String userId) { List listMap = getSpaceAndSpacenum(hugeClient); Map> spaceMap = HubbleUtil.uncheckedCast(listMap.get(0)); - List adminSpaces = spaceMap.get(userId); + List adminSpaces = spaceMap.get(user.name()); + if (adminSpaces == null) { + adminSpaces = new ArrayList<>(); + } List resSpaces = new ArrayList<>(); for (String space : spaces) { - if (hugeClient.graphSpace().checkDefaultRole(space, userId, "analyst")) { + if (this.graphSpaceUserService.hasGraphSpaceAccess( + hugeClient, space, user.name())) { resSpaces.add(space); } } @@ -158,6 +175,7 @@ public UserEntity get(HugeClient hugeClient, String userId) { userEntity.setAdminSpaces(adminSpaces); userEntity.setSpacenum(adminSpaces.size()); userEntity.setResSpaces(resSpaces); + this.populatePermissionPresets(hugeClient, userEntity); } else { userEntity.setSuperadmin(isStandaloneAdmin(user.name())); userEntity.setAdminSpaces(new ArrayList<>()); @@ -168,8 +186,7 @@ public UserEntity get(HugeClient hugeClient, String userId) { } public UserEntity getpersonal(HugeClient hugeClient, String username) { - AuthManager auth = hugeClient.auth(); - User user = auth.getUserByName(username); + User user = currentUser(hugeClient, username); if (user == null) { throw new InternalException("auth.user.get.%s Not Exits", username); @@ -185,13 +202,14 @@ public UserEntity getpersonal(HugeClient hugeClient, String username) { adminSpaces.add(space); } if (hugeClient.auth().isSpaceAdmin(space) || - hugeClient.auth().checkDefaultRole(space, "analyst")) { + hasCurrentUserAccess(hugeClient, space)) { resSpaces.add(space); } } userEntity.setAdminSpaces(adminSpaces); userEntity.setSpacenum(adminSpaces.size()); userEntity.setResSpaces(resSpaces); + this.populateCurrentUserPermissionPresets(hugeClient, userEntity); } else { userEntity.setSuperadmin(isStandaloneAdmin(username)); userEntity.setAdminSpaces(new ArrayList<>()); @@ -202,6 +220,10 @@ public UserEntity getpersonal(HugeClient hugeClient, String username) { } public void add(HugeClient client, UserEntity ue) { + boolean permissionPresets = isPdEnabled() && + client.supportsDefaultRole(); + this.validatePermissionMutation(client, ue, true, + permissionPresets); User user = new User(); user.name(ue.getName()); user.password(ue.getPassword()); @@ -214,15 +236,45 @@ public void add(HugeClient client, UserEntity ue) { } User newUser = client.auth().createUser(user); - if (ue.getAdminSpaces() != null) { - for (String graphspace : ue.getAdminSpaces()) { - client.auth().addSpaceAdmin(ue.getName(), graphspace); + boolean superAdminAttempted = false; + try { + if (permissionPresets) { + this.graphSpaceUserService + .applyPermissionPresetsForNewAccount( + client, ue.getName(), + ue.getGraphspacePermissions(), + ue.getPermissionPreset()); } + if (permissionPresets && newUser != null && ue.isSuperadmin()) { + superAdminAttempted = true; + client.auth().addSuperAdmin(ue.getName()); + } + } catch (RuntimeException error) { + this.rollbackNewAccount(client, newUser, ue.getName(), + superAdminAttempted, error); + throw error; } + } - if (newUser != null && ue.isSuperadmin()) { - // add superadmin - client.auth().addSuperAdmin(newUser.id().toString()); + private void rollbackNewAccount(HugeClient client, User user, + String username, + boolean superAdminAttempted, + RuntimeException failure) { + if (superAdminAttempted) { + this.suppressRollback( + () -> client.auth().delSuperAdmin(username), failure); + } + if (user != null) { + this.suppressRollback( + () -> client.auth().deleteUser(user.id()), failure); + } + } + + private void suppressRollback(Runnable rollback, RuntimeException failure) { + try { + rollback.run(); + } catch (RuntimeException rollbackError) { + failure.addSuppressed(rollbackError); } } @@ -343,6 +395,149 @@ protected UserEntity convert(HugeClient client, User user) { return u; } + private void populatePermissionPresets(HugeClient client, + UserEntity userEntity) { + List> permissions = new ArrayList<>(); + if (userEntity.isSuperadmin()) { + userEntity.setGraphspacePermissions(permissions); + userEntity.setPermissionPreset("SUPER_ADMIN"); + return; + } + if (!client.supportsDefaultRole()) { + userEntity.setGraphspacePermissions(permissions); + userEntity.setPermissionPreset("LEGACY_CUSTOM"); + return; + } + List graphSpaces = client.graphSpace().listGraphSpace(); + boolean legacyCustom = false; + for (String graphSpace : graphSpaces) { + legacyCustom |= this.graphSpaceUserService.hasCustomRoles(client, graphSpace, userEntity.getId()); + if (userEntity.getAdminSpaces() != null && + userEntity.getAdminSpaces().contains(graphSpace)) { + permissions.add(permission(graphSpace, "GS_ADMIN")); + continue; + } + if (client.graphSpace().checkDefaultRole( + graphSpace, userEntity.getName(), "analyst")) { + permissions.add(permission(graphSpace, "GS_READ_WRITE")); + } else if (hasObserverRole(client, graphSpace, userEntity.getName())) { + permissions.add(permission(graphSpace, "GS_READ_ONLY")); + } + } + userEntity.setGraphspacePermissions(permissions); + if (legacyCustom) { + userEntity.setPermissionPreset("LEGACY_CUSTOM"); + } + } + + private void populateCurrentUserPermissionPresets( + HugeClient client, UserEntity userEntity) { + List> permissions = new ArrayList<>(); + if (userEntity.isSuperadmin()) { + userEntity.setGraphspacePermissions(permissions); + userEntity.setPermissionPreset("SUPER_ADMIN"); + return; + } + if (!client.supportsDefaultRole()) { + userEntity.setGraphspacePermissions(permissions); + userEntity.setPermissionPreset("LEGACY_CUSTOM"); + return; + } + for (String graphSpace : client.graphSpace().listGraphSpace()) { + if (userEntity.getAdminSpaces() != null && + userEntity.getAdminSpaces().contains(graphSpace)) { + permissions.add(permission(graphSpace, "GS_ADMIN")); + } else if (client.auth().checkDefaultRole( + graphSpace, "analyst")) { + permissions.add(permission(graphSpace, "GS_READ_WRITE")); + } else if (hasCurrentUserObserverRole(client, graphSpace)) { + permissions.add(permission(graphSpace, "GS_READ_ONLY")); + } + } + userEntity.setGraphspacePermissions(permissions); + } + + private void populatePermissionPresets(HugeClient client, + Collection users) { + if (!client.supportsDefaultRole()) { + for (UserEntity user : users) { + user.setGraphspacePermissions(new ArrayList<>()); + user.setPermissionPreset(user.isSuperadmin() ? "SUPER_ADMIN" : "LEGACY_CUSTOM"); + } + return; + } + List graphSpaces = client.graphSpace().listGraphSpace(); + for (UserEntity user : users) { + List> values = new ArrayList<>(); + if (user.isSuperadmin()) { + user.setGraphspacePermissions(values); + user.setPermissionPreset("SUPER_ADMIN"); + continue; + } + boolean legacyCustom = false; + if (user.getAdminSpaces() != null) { + for (String graphSpace : user.getAdminSpaces()) { + values.add(permission(graphSpace, "GS_ADMIN")); + } + } + for (String graphSpace : graphSpaces) { + legacyCustom |= this.graphSpaceUserService.hasCustomRoles(client, graphSpace, user.getId()); + if (user.getAdminSpaces() != null && + user.getAdminSpaces().contains(graphSpace)) { + continue; + } + if (client.graphSpace().checkDefaultRole( + graphSpace, user.getName(), "analyst")) { + values.add(permission(graphSpace, "GS_READ_WRITE")); + } else if (hasObserverRole(client, graphSpace, user.getName())) { + values.add(permission(graphSpace, "GS_READ_ONLY")); + } + } + user.setGraphspacePermissions(values); + if (legacyCustom) { + user.setPermissionPreset("LEGACY_CUSTOM"); + } + } + } + + private static Map permission(String graphSpace, + String preset) { + Map permission = new HashMap<>(); + permission.put("graphspace", graphSpace); + permission.put("permission_preset", preset); + return permission; + } + + private static boolean hasObserverRole(HugeClient client, + String graphSpace, + String username) { + return client.graphSpace().checkDefaultRole( + graphSpace, username, "observer"); + } + + 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 hasCurrentUserObserverRole(client, graphSpace); + } + + private static boolean hasCurrentUserObserverRole(HugeClient client, + String graphSpace) { + try { + return client.auth().checkDefaultRole(graphSpace, "observer"); + } catch (ServerException e) { + if (e.status() == 403) { + return false; + } + throw e; + } + } + protected List getSpaceAndSpacenum(HugeClient hugeClient) { AuthManager auth = hugeClient.auth(); List listMap = new ArrayList<>(); @@ -376,6 +571,14 @@ protected List getSpaceAndSpacenum(HugeClient hugeClient) { } public void update(HugeClient hugeClient, UserEntity userEntity) { + boolean permissionPresets = isPdEnabled() && + hugeClient.supportsDefaultRole(); + boolean permissionMutation = userEntity.getPermissionPreset() != null; + this.validatePermissionMutation(hugeClient, userEntity, false, + permissionPresets); + if (isPdEnabled() && !permissionPresets) { + this.validateLegacyPermissionUpdate(hugeClient, userEntity); + } User user = new User(); user.setId(userEntity.getId()); user.name(userEntity.getName()); @@ -386,24 +589,136 @@ public void update(HugeClient hugeClient, UserEntity userEntity) { if (isPdEnabled()) { user.nickname(userEntity.getNickname()); } - updateAdminSpace(hugeClient, userEntity.getName(), userEntity.getAdminSpaces()); + if (permissionPresets && permissionMutation) { + this.updateModernPermissionAccount(hugeClient, user, userEntity); + return; + } + hugeClient.auth().updateUser(user); + } - // 设置超级管理员权限 - boolean curSuperAdmin = isSuperAdmin(hugeClient, user.id().toString()); - if (curSuperAdmin && !userEntity.isSuperadmin()) { - hugeClient.auth().delSuperAdmin(user.id().toString()); + private void validateLegacyPermissionUpdate(HugeClient client, + UserEntity user) { + if (user.getPermissionPreset() != null || + user.getGraphspacePermissions() != null && + !user.getGraphspacePermissions().isEmpty()) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); + } + if (user.getAdminSpaces() != null) { + List current = this.listAdminSpace(client, user.getName()); + if (!new HashSet<>(current).equals( + new HashSet<>(user.getAdminSpaces()))) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); + } } - if (!curSuperAdmin && userEntity.isSuperadmin()) { - hugeClient.auth().addSuperAdmin(user.id().toString()); + if (user.hasSuperadmin() && + this.isSuperAdmin(client, user.getId()) != user.isSuperadmin()) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); } + } - hugeClient.auth().updateUser(user); + 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) { + if (create && (user.isSuperadmin() || + user.getPermissionPreset() != null || + user.getAdminSpaces() != null && + !user.getAdminSpaces().isEmpty() || + user.getGraphspacePermissions() != null && + !user.getGraphspacePermissions().isEmpty())) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); + } + return; + } + String preset = user.getPermissionPreset(); + if (preset == null) { + if (create) { + throw new ParameterizedException( + "auth.permission-preset.account-required"); + } + return; + } + if (!create && user.getPassword() != null && + !user.getPassword().isEmpty()) { + throw new ParameterizedException( + "Update password and permissions separately"); + } + 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) { - AuthManager auth = hugeClient.auth(); - User user = auth.getUserByName(username); + if (!hugeClient.supportsPersonalProfileUpdate()) { + throw new ParameterizedException( + "auth.profile.update-unsupported"); + } + User user = currentUser(hugeClient, username); if (isPdEnabled()) { user.nickname(nickname); } else { @@ -430,7 +745,7 @@ public Response updatepwd(HugeClient hugeClient, String username, } // Must fetch user first to get the ID, otherwise updateUser sends // PUT to the collection path (no {id}) and gets HTTP 405. - User user = hugeClient.auth().getUserByName(username); + User user = currentUser(hugeClient, username); user.password(newpwd); hugeClient.auth().updateUser(user); return Response.builder() @@ -438,12 +753,23 @@ public Response updatepwd(HugeClient hugeClient, String username, .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 listAdminSpace(HugeClient hugeClient, String username) { if (!isPdEnabled()) { return new ArrayList<>(); } - AuthManager auth = hugeClient.auth(); - List users = auth.listUsers(); List spaces = hugeClient.graphSpace().listGraphSpace(); List adminspace = new ArrayList(); for (String space : spaces) { @@ -463,15 +789,24 @@ public void updateAdminSpace(HugeClient hugeClient, String username, if (adminspaces == null || !isPdEnabled()) { return; } + if (!hugeClient.supportsDefaultRole()) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); + } List oldadminspaces = listAdminSpace(hugeClient, username); + User account = hugeClient.findUserByName(username); + E.checkNotNull(account, "User"); for (String adminspace : adminspaces) { if (!oldadminspaces.contains(adminspace)) { - hugeClient.auth().addSpaceAdmin(username, adminspace); + this.graphSpaceUserService.applySpacePreset(hugeClient, adminspace, account.id().toString(), + account.name(), + "GS_ADMIN"); } } for (String oldadminspace : oldadminspaces) { if (!adminspaces.contains(oldadminspace)) { - hugeClient.auth().delSpaceAdmin(username, oldadminspace); + this.graphSpaceUserService.removeSpacePreset(hugeClient, oldadminspace, + account.id().toString()); } } } @@ -507,9 +842,8 @@ public boolean isSuperAdmin(HugeClient client, String uid) { 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/graphs/GraphsService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java index d2eb78e8d..059146209 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/graphs/GraphsService.java @@ -234,7 +234,10 @@ public List> sortedGraphsProfile(HugeClient client, private List> listGraphProfiles(GraphsManager graphs, String query) { try { - return graphs.listProfile(query); + List> profiles = graphs.listProfile(query); + if (!profiles.isEmpty()) { + return profiles; + } } catch (RuntimeException e) { if (e instanceof ServerException) { int status = ((ServerException) e).status(); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java index ecbf666ed..e2578aa27 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/load/JobManagerService.java @@ -19,6 +19,8 @@ package org.apache.hugegraph.service.load; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.List; @@ -119,6 +121,38 @@ public List listAll() { return this.mapper.selectList(null); } + public List listByGraphSpaces( + Collection graphSpaces) { + if (graphSpaces != null && graphSpaces.isEmpty()) { + return Collections.emptyList(); + } + QueryWrapper query = Wrappers.query(); + if (graphSpaces != null) { + query.in("graphspace", graphSpaces); + } + query.orderByDesc("create_time"); + return this.mapper.selectList(query); + } + + public IPage listByGraphSpaces( + Collection graphSpaces, int pageNo, int pageSize, + String content) { + Page page = new Page<>(pageNo, + PageUtil.boundedSize(pageSize)); + if (graphSpaces != null && graphSpaces.isEmpty()) { + return page; + } + QueryWrapper query = Wrappers.query(); + if (graphSpaces != null) { + query.in("graphspace", graphSpaces); + } + if (content != null && !content.isEmpty()) { + query.like("job_name", content); + } + query.orderByDesc("create_time"); + return this.mapper.selectPage(page, query); + } + public IPage listAll(int pageNo, int pageSize, String content) { QueryWrapper query = Wrappers.query(); if (content != null && !content.isEmpty()) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java index ba81f484b..b95fca4db 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/DefaultOperationsDataService.java @@ -50,6 +50,12 @@ @Service public class DefaultOperationsDataService implements OperationsDataService { + private static final Set PD_FACTS = Set.of( + "graphs", "partitions", "replicas", "stores", "stores_up", + "data_size_bytes"); + private static final Set STORE_FACTS = Set.of( + "capacity_total_bytes", "capacity_used_bytes"); + private final OperationsCollector collector; private final long ttlMillis; private final Clock clock; @@ -265,10 +271,7 @@ private Snapshot mergeFailedSources(Snapshot current, Snapshot previous) { .map(node -> staleNode(node, source)) .forEach(nodes::add); } - if ("pd".equals(sourceName)) { - facts.clear(); - facts.putAll(previous.getFacts()); - } + this.mergeFailedFacts(facts, previous.getFacts(), sourceName); } if (!stale) { return current; @@ -279,6 +282,25 @@ private Snapshot mergeFailedSources(Snapshot current, Snapshot previous) { "partial_refresh_failed", sources, nodes, facts); } + private void mergeFailedFacts(Map current, + Map previous, + String source) { + Set owned; + if ("pd".equals(source)) { + owned = PD_FACTS; + } else if ("stores".equals(source)) { + owned = STORE_FACTS; + } else { + return; + } + for (String fact : owned) { + current.remove(fact); + if (previous.containsKey(fact)) { + current.put(fact, previous.get(fact)); + } + } + } + private boolean mergeFailedMetricGroups(List current, List previous, String nodeType) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java index 74c8c1bf4..7b59bd8dc 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/LiveOperationsCollector.java @@ -270,6 +270,7 @@ private void collectPd(boolean includeMetrics, long now, Topology topology = this.parser.parseTopology(EMPTY_CLUSTER, stores, now); this.mergeNodes(nodes, topology.getNodes()); + facts.putAll(topology.getFacts()); storesParsed = true; } catch (MalformedUpstreamException e) { storesStatus = malformed(now); diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsPayloadParser.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsPayloadParser.java index 231244c11..c5631914d 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsPayloadParser.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/op/OperationsPayloadParser.java @@ -90,6 +90,9 @@ public Topology parseTopology(String clusterPayload, String storesPayload, this.putLong(facts, "replicas", cluster.get("shardCount")); this.putLong(facts, "stores", cluster.get("storeSize")); this.putLong(facts, "stores_up", cluster.get("onlineStoreSize")); + this.putKilobytesAsBytes(facts, "data_size_bytes", + cluster.get("dataSize")); + this.putCapacityFacts(facts, storeNodes.values()); return new Topology(this.clusterStatus(this.text(cluster, "state")), nodes, facts); } @@ -452,6 +455,47 @@ private void putLong(Map target, String key, JsonNode value) { } } + private void putKilobytesAsBytes(Map target, String key, + JsonNode value) { + Long kilobytes = this.longValue(value); + if (kilobytes == null || kilobytes < 0L) { + return; + } + try { + target.put(key, Math.multiplyExact(kilobytes, 1024L)); + } catch (ArithmeticException ignored) { + // An overflowing upstream value is unavailable, never a fake size. + } + } + + private void putCapacityFacts(Map target, + Iterable stores) { + long total = 0L; + long available = 0L; + boolean found = false; + try { + for (JsonNode store : stores) { + Long storeTotal = this.longValue(store.get("capacity")); + Long storeAvailable = this.longValue(store.get("available")); + if (storeTotal == null || storeAvailable == null || + storeTotal < 0L || storeAvailable < 0L || + storeAvailable > storeTotal) { + return; + } + total = Math.addExact(total, storeTotal); + available = Math.addExact(available, storeAvailable); + found = true; + } + if (found) { + target.put("capacity_total_bytes", total); + target.put("capacity_used_bytes", + Math.subtractExact(total, available)); + } + } catch (ArithmeticException ignored) { + // Partial or overflowing capacity is less useful than unavailable. + } + } + private void putMetric(Map target, String key, JsonNode value) { Long number = this.longValue(value); @@ -521,16 +565,27 @@ private String clusterStatus(String state) { return "UNKNOWN"; } String value = state.toUpperCase(Locale.ROOT); - if (value.contains("OK") || value.equals("UP")) { - return "UP"; + switch (value) { + case "CLUSTER_OK": + case "OK": + case "UP": + return "UP"; + case "CLUSTER_WARN": + case "CLUSTER_NOT_READY": + case "CLUSTER_OFFLINE": + case "WARN": + case "WARNING": + case "DEGRADED": + case "NOT_READY": + case "OFFLINE": + return "DEGRADED"; + case "CLUSTER_FAULT": + case "FAULT": + case "DOWN": + return "DOWN"; + default: + return "UNKNOWN"; } - if (value.contains("WARN") || value.contains("DEGRADED")) { - return "DEGRADED"; - } - if (value.contains("DOWN") || value.contains("FAULT")) { - return "DOWN"; - } - return "UNKNOWN"; } private String health(String state) { diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/ExecuteHistoryService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/ExecuteHistoryService.java index a56e85188..1af1a9bbd 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/ExecuteHistoryService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/ExecuteHistoryService.java @@ -130,7 +130,14 @@ private void checkTypeValid(int type) { } public ExecuteHistory get(HugeClient client, int id) { - ExecuteHistory history = this.mapper.selectById(id); + QueryWrapper query = Wrappers.query(); + query.eq("id", id) + .eq("graphspace", client.getGraphSpaceName()) + .eq("graph", client.getGraphName()); + ExecuteHistory history = this.mapper.selectOne(query); + if (history == null) { + return null; + } if (history.getType().equals(ExecuteType.GREMLIN_ASYNC)) { try { Task task = client.task().get(history.getAsyncId()); @@ -160,11 +167,18 @@ public void update(ExecuteHistory history) { @Transactional(isolation = Isolation.READ_COMMITTED) public void remove(HugeClient client, int id) { - ExecuteHistory history = this.mapper.selectById(id); + ExecuteHistory history = this.get(client, id); + if (history == null) { + return; + } if (history.getType().equals(ExecuteType.GREMLIN_ASYNC)) { client.task().delete(history.getAsyncId()); } - if (this.mapper.deleteById(id) != 1) { + QueryWrapper query = Wrappers.query(); + query.eq("id", id) + .eq("graphspace", client.getGraphSpaceName()) + .eq("graph", client.getGraphName()); + if (this.mapper.delete(query) != 1) { throw new InternalException("entity.delete.failed", history); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/GremlinCollectionService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/GremlinCollectionService.java index 963c77e5e..a6b016679 100644 --- a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/GremlinCollectionService.java +++ b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/query/GremlinCollectionService.java @@ -111,8 +111,12 @@ private static void checkSingleOrder(Boolean nameOrderAsc, } } - public GremlinCollection get(int id) { - return this.mapper.selectById(id); + public GremlinCollection get(String graphSpace, String graph, int id) { + QueryWrapper query = Wrappers.query(); + query.eq("id", id) + .eq("graphspace", graphSpace) + .eq("graph", graph); + return this.mapper.selectOne(query); } public GremlinCollection getByName(String graphSpace, String graph, @@ -137,15 +141,24 @@ public void save(GremlinCollection collection) { } @Transactional(isolation = Isolation.READ_COMMITTED) - public void update(GremlinCollection collection) { - if (this.mapper.updateById(collection) != 1) { + public void update(String graphSpace, String graph, + GremlinCollection collection) { + QueryWrapper query = Wrappers.query(); + query.eq("id", collection.getId()) + .eq("graphspace", graphSpace) + .eq("graph", graph); + if (this.mapper.update(collection, query) != 1) { throw new InternalException("entity.update.failed", collection); } } @Transactional(isolation = Isolation.READ_COMMITTED) - public void remove(int id) { - if (this.mapper.deleteById(id) != 1) { + public void remove(String graphSpace, String graph, int id) { + QueryWrapper query = Wrappers.query(); + query.eq("id", id) + .eq("graphspace", graphSpace) + .eq("graph", graph); + if (this.mapper.delete(query) != 1) { throw new InternalException("entity.delete.failed", id); } } diff --git a/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/service/space/GraphSpaceService.java index b5b85dca5..498c56541 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.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.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 Map metrics(HugeClient client) { public IPage> queryPage(HugeClient client, String query, String createTime, int pageNo, int pageSize) { - List> 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> queryAllGs(HugeClient client, String query, String createTime) { - List> 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 info : results) { - removeSensitiveFields(info); - String name = info.get("name").toString(); - info.put("graphspace_admin", - userService.listGraphSpaceAdmin(client, name)); - Map statisticTotal = evCount(client, name); - info.put("statistic", statisticTotal); - } + return queryAllGsMetadata(client, query, createTime).stream() + .map(info -> graphSpaceView(client, info)) + .collect(Collectors.toList()); + } + + private List> queryAllGsMetadata( + HugeClient client, String query, String createTime) { + List> 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 graphSpaceView( + HugeClient client, Map source) { + Map 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> queryAccessibleGs(HugeClient client, String query, String createTime) { + return queryAccessibleSpaces(client, query, createTime).stream() + .map(space -> accessibleView(client, space)) + .collect(Collectors.toList()); + } + + public IPage> 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 queryAccessibleSpaces(HugeClient client, + String query, + String createTime) { String prefix = query == null ? "" : query; String after = createTime == null ? "" : createTime; - List> results = client.graphSpace() + List results = client.graphSpace() .listGraphSpace().stream() .map(client.graphSpace()::getGraphSpace) .filter(space -> space != null && @@ -157,26 +186,164 @@ public List> queryAccessibleGs(HugeClient client, space.getNickname().contains(prefix))) .filter(space -> space.getCreateTime() == null || space.getCreateTime().compareTo(after) > 0) - .filter(space -> !space.isAuth() || - client.auth().isSpaceAdmin(space.getName()) || - client.auth().checkDefaultRole( - space.getName(), "analyst")) - .map(space -> { - GraphSpaceEntity entity = - GraphSpaceEntity.fromGraphSpace(space); - entity.setStatistic(evCount(client, space.getName())); - Map info = toView(entity); - info.put("authed", true); - info.put("default", false); - return info; - }) + .filter(space -> canCurrentUserAccess(client, space)) + .collect(Collectors.toList()); + Collections.sort(results, (a, b) -> new BuiltInFirst().compare( + a.getName(), b.getName())); + return results; + } + + private Map accessibleView(HugeClient client, + GraphSpace space) { + GraphSpaceEntity entity = GraphSpaceEntity.fromGraphSpace(space); + entity.setStatistic(evCount(client, space.getName())); + Map 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.auth().checkDefaultRole(graphSpace, "observer"); + } + + private static boolean canCurrentUserAccess(HugeClient client, + GraphSpace graphSpace) { + return !graphSpace.isAuth() || + client.auth().isSuperAdmin() || + client.auth().isSpaceAdmin(graphSpace.getName()) || + hasCurrentUserAccess(client, graphSpace.getName()); + } + + public List> queryAnonymousGs(HugeClient client, + String query, + String createTime) { + return queryAnonymousSpaces(client, query, createTime).stream() + .map(space -> anonymousView(client, space)) + .collect(Collectors.toList()); + } + + public List listAnonymous(HugeClient client) { + return queryAnonymousSpaces(client, "", "").stream() + .map(GraphSpace::getName) + .collect(Collectors.toList()); + } + + public List listAccessible(HugeClient client) { + return queryAccessibleSpaces(client, "", "").stream() + .map(GraphSpace::getName) + .collect(Collectors.toList()); + } + + public Map getAnonymous(HugeClient client, + String graphSpace) { + return anonymousView(client, requirePublicSpace(client, graphSpace)); + } + + public boolean isAuthForAnonymous(HugeClient client, String graphSpace) { + return requirePublicSpace(client, graphSpace).isAuth(); + } + + public IPage> queryAnonymousGsPage( + HugeClient client, String query, String createTime, + int pageNo, int pageSize) { + List spaces = + queryAnonymousSpaces(client, query, createTime); + return pageAndMap(spaces, pageNo, pageSize, + space -> anonymousView(client, space)); + } + + static IPage pageAndMap( + List sourceRecords, int pageNo, int pageSize, + Function 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 source = PageUtil.page(sourceRecords, pageNo, pageSize); + List records = source.getRecords().stream() + .map(mapper) .collect(Collectors.toList()); - Collections.sort(results, (a, b) -> - new BuiltInFirst().compare(a.get("name").toString(), - b.get("name").toString())); + Page 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 queryAnonymousSpaces(HugeClient client, + String query, + String createTime) { + String prefix = query == null ? "" : query; + String after = createTime == null ? "" : createTime; + List results = client.graphSpace() + .listGraphSpace().stream() + .map(client.graphSpace()::getGraphSpace) + .filter(space -> space != null && + (space.getName().contains(prefix) || + space.getNickname() != null && + space.getNickname().contains(prefix))) + .filter(space -> space.getCreateTime() == null || space.getCreateTime().compareTo(after) > 0) + .filter(space -> !space.isAuth()) + .collect(Collectors.toList()); + Collections.sort(results, (a, b) -> new BuiltInFirst().compare( + a.getName(), b.getName())); return results; } + private Map anonymousView(HugeClient client, + GraphSpace space) { + GraphSpaceEntity entity = GraphSpaceEntity.fromGraphSpace(space); + entity.setStatistic(evCount(client, space.getName())); + Map info = toView(entity); + info.put("authed", true); + info.put("default", false); + return info; + } + + public GraphSpace requirePublicSpace(HugeClient client, + String graphSpace) { + GraphSpace space = graphSpaceOrUnavailable(client, graphSpace); + if (space.isAuth()) { + throw unavailableGraphSpace(); + } + return space; + } + + private static GraphSpace graphSpaceOrUnavailable(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) { + throw unavailableGraphSpace(); + } + return space; + } + + private static ExternalException unavailableGraphSpace() { + return new ExternalException(HttpStatus.NOT_FOUND.value(), + "GraphSpace is unavailable"); + } + public Map toView(GraphSpaceEntity entity) { Map info = HubbleUtil.uncheckedCast( JsonUtil.fromJson(JsonUtil.toJson(entity), Map.class)); @@ -200,7 +367,7 @@ private static void removeSensitiveFields(Map info) { * @param graphSpace * @return */ - Map evCount(HugeClient client, String graphSpace) { + public Map evCount(HugeClient client, String graphSpace) { Long vertexTotal = 0L; Long edgeTotal = 0L; Map statisticTotal = new HashMap<>(); @@ -318,6 +485,11 @@ public boolean isAuth(HugeClient client, String graphSpace) { return space.isAuth(); } + public boolean isAuthForAccessible(HugeClient client, + String graphSpace) { + return requireAccessibleSpace(client, graphSpace).isAuth(); + } + public List listAll(HugeClient client) { List result = client.graphSpace().listGraphSpace().stream() .collect(Collectors.toList()); @@ -342,17 +514,34 @@ public GraphSpaceEntity getWithAdmins(HugeClient authClient, String graphspace) throw new InternalException("graphspace.get.{} Not Exits", graphspace); } + return this.withAdmins(authClient, space); + } - GraphSpaceEntity graphSpaceEntity - = GraphSpaceEntity.fromGraphSpace(space); + public GraphSpaceEntity getAccessibleWithAdmins(HugeClient client, + String graphSpace) { + return this.withAdmins(client, requireAccessibleSpace(client, + graphSpace)); + } - if (authClient.auth().isSuperAdmin()) { - graphSpaceEntity.graphspaceAdmin = - userService.listGraphSpaceAdmin(authClient, graphspace); + private GraphSpaceEntity withAdmins(HugeClient client, + GraphSpace graphSpace) { + GraphSpaceEntity entity = GraphSpaceEntity.fromGraphSpace(graphSpace); + if (client.auth().isSuperAdmin()) { + String name = graphSpace.getName(); + entity.graphspaceAdmin = + userService.listGraphSpaceAdmin(client, name); } - graphSpaceEntity.setStatistic(evCount(authClient, graphspace)); + entity.setStatistic(evCount(client, graphSpace.getName())); + return entity; + } - return graphSpaceEntity; + public GraphSpace requireAccessibleSpace(HugeClient client, + String graphSpace) { + GraphSpace space = graphSpaceOrUnavailable(client, graphSpace); + if (!canCurrentUserAccess(client, space)) { + throw unavailableGraphSpace(); + } + return space; } public void delete(HugeClient authClient, String graphspace) { 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 82c9a750b..60f221907 100644 --- a/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties +++ b/hugegraph-hubble/hubble-be/src/main/resources/i18n/messages.properties @@ -31,6 +31,16 @@ server.capability.vermeer-compute-token-auth.unavailable=Vermeer compute is unav auth.user.batch-create.failed=Failed to create users: {0} 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 7b1f28aef..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 @@ -31,6 +31,16 @@ server.capability.vermeer-compute-token-auth.unavailable=Vermeer 计算需上游 auth.user.batch-create.failed=以下用户创建失败:{0} 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/config/HubbleConfigEnvironmentTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/config/HubbleConfigEnvironmentTest.java new file mode 100644 index 000000000..e15649fce --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/config/HubbleConfigEnvironmentTest.java @@ -0,0 +1,68 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.config; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.options.HubbleOptions; +import org.junit.Assert; +import org.junit.Test; + +public class HubbleConfigEnvironmentTest { + + @Test + public void testAuthModeCanBeOverriddenByEnvironment() { + HugeConfig config = config(true); + + HubbleConfig.applyEnvironmentOverrides( + config, Map.of("HUBBLE_AUTH_ENABLED", "false")); + + Assert.assertFalse(config.get(HubbleOptions.AUTH_ENABLED)); + } + + @Test + public void testMissingEnvironmentKeepsFileValue() { + HugeConfig config = config(false); + + HubbleConfig.applyEnvironmentOverrides(config, Collections.emptyMap()); + + Assert.assertFalse(config.get(HubbleOptions.AUTH_ENABLED)); + } + + @Test(expected = ExternalException.class) + public void testAuthModeRejectsInvalidEnvironmentValue() { + HugeConfig config = config(true); + + HubbleConfig.applyEnvironmentOverrides( + config, Map.of("HUBBLE_AUTH_ENABLED", "disabled")); + } + + private static HugeConfig config(boolean authEnabled) { + if (!OptionSpace.containKey(HubbleOptions.AUTH_ENABLED.name())) { + OptionSpace.register("hubble-environment-test", + HubbleOptions.instance()); + } + Map properties = new HashMap<>(); + properties.put(HubbleOptions.AUTH_ENABLED.name(), authEnabled); + return new HugeConfig(properties); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/AccountMutationAuthorizationTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/AccountMutationAuthorizationTest.java index 2225fcfdb..3eabedb0a 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/AccountMutationAuthorizationTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/AccountMutationAuthorizationTest.java @@ -491,12 +491,12 @@ public void testOrdinaryUserCannotAssignGraphSpaceMembership() { () -> controller.create("SPACE", member)); org.junit.Assert.assertTrue(failure.getMessage() - .contains("graphspace members")); + .contains("authorization objects")); Mockito.verifyZeroInteractions(memberService); } @Test - public void testCurrentSpaceAdminCanAssignGraphSpaceMembership() { + public void testCurrentSpaceAdminCannotUseLegacyRoleMembershipRoute() { TestGraphSpaceUserController controller = new TestGraphSpaceUserController(this.client, "manager"); GraphSpaceUserService memberService = @@ -510,12 +510,11 @@ public void testCurrentSpaceAdminCanAssignGraphSpaceMembership() { .thenReturn(true); UserView member = new UserView("bob", "bob", Collections.emptyList()); - Mockito.when(memberService.createOrUpdate(this.client, "SPACE", - member)) - .thenReturn(member); - org.junit.Assert.assertSame(member, - controller.create("SPACE", member)); + assertForbidden(() -> controller.create("SPACE", member)); + assertForbidden(() -> controller.createOrUpdate("SPACE", "bob", + member)); + Mockito.verifyZeroInteractions(memberService); } @Test diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java index 177b04147..e96ae5bac 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthMutationAuthorizationTest.java @@ -140,7 +140,7 @@ public void testOrdinaryUserCannotMutateTargets() throws Exception { } @Test - public void testCurrentSpaceAdminCanManageScopedAuthorizationResources() + public void testCurrentSpaceAdminCanManageMembersOnly() throws Exception { Mockito.when(this.authorizationService.isAssignSpaceAdmin( this.client, "SPACE")) @@ -149,8 +149,11 @@ public void testCurrentSpaceAdminCanManageScopedAuthorizationResources() Mockito.eq(this.client), Mockito.any())) .thenReturn("SPACEADMIN"); - this.assertScopedReadsAllowed(); - this.assertScopedCreatesAllowed(); + this.assertLowLevelReadsForbidden(); + ReadRoute members = this.memberReadRoute(); + mvc(members.controller).perform(members.request) + .andExpect(status().isOk()); + this.assertScopedCreatesForbidden(); } @Test @@ -191,6 +194,12 @@ private void assertScopedReadsAllowed() throws Exception { } } + private void assertLowLevelReadsForbidden() throws Exception { + for (ReadRoute route : this.lowLevelReadRoutes()) { + assertForbidden(mvc(route.controller), route.request); + } + } + private void assertScopedReadsForbidden() throws Exception { for (ReadRoute route : this.scopedReadRoutes()) { assertForbidden(mvc(route.controller), route.request); @@ -198,6 +207,14 @@ private void assertScopedReadsForbidden() throws Exception { } private ReadRoute[] scopedReadRoutes() { + ReadRoute[] lowLevel = this.lowLevelReadRoutes(); + return new ReadRoute[]{ + lowLevel[0], lowLevel[1], lowLevel[2], lowLevel[3], + this.memberReadRoute() + }; + } + + private ReadRoute[] lowLevelReadRoutes() { return new ReadRoute[]{ new ReadRoute(this.prepare(new TestBelongController( this.client), "belongService", @@ -214,12 +231,15 @@ private ReadRoute[] scopedReadRoutes() { new ReadRoute(this.prepare(new TestTargetController( this.client), "targetService", Mockito.mock(TargetService.class)), - get("/api/v1.3/graphspaces/SPACE/auth/targets")), - new ReadRoute(this.prepare(new TestGraphSpaceUserController( + get("/api/v1.3/graphspaces/SPACE/auth/targets")) + }; + } + + private ReadRoute memberReadRoute() { + return new ReadRoute(this.prepare(new TestGraphSpaceUserController( this.client), "userService", Mockito.mock(GraphSpaceUserService.class)), - get("/api/v1.3/graphspaces/SPACE/auth/users")) - }; + get("/api/v1.3/graphspaces/SPACE/auth/users")); } private void assertScopedCreatesAllowed() throws Exception { @@ -242,6 +262,25 @@ private void assertScopedCreatesAllowed() throws Exception { .andExpect(status().isOk()); } + private void assertScopedCreatesForbidden() throws Exception { + BelongController belong = this.prepare(new TestBelongController( + this.client), "belongService", Mockito.mock(BelongService.class)); + AccessController access = this.prepare(new TestAccessController( + this.client), "accessService", Mockito.mock(AccessService.class)); + TargetController target = this.prepare(new TestTargetController( + this.client), "targetService", Mockito.mock(TargetService.class)); + + assertForbidden(mvc(belong), + post("/api/v1.3/graphspaces/SPACE/auth/belongs") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"role_id\":\"r\",\"user_id\":\"u\"}")); + assertForbidden(mvc(access), + post("/api/v1.3/graphspaces/SPACE/auth/accesses") + .contentType(MediaType.APPLICATION_JSON).content("{}")); + assertForbidden(mvc(target), + post("/api/v1.3/graphspaces/SPACE/auth/targets").contentType(MediaType.APPLICATION_JSON).content("{}")); + } + private T prepare(T controller, String serviceField, Object service) { diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/auth/GraphSpaceAuthOwnershipTest.java index 4516f1286..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 @@ -35,6 +35,8 @@ import org.apache.hugegraph.controller.BaseController; import org.apache.hugegraph.driver.AuthManager; +import org.apache.hugegraph.driver.GraphSpaceManager; +import org.apache.hugegraph.driver.GraphsManager; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.AccessEntity; import org.apache.hugegraph.entity.auth.BelongEntity; @@ -42,6 +44,7 @@ import org.apache.hugegraph.entity.auth.UserEntity; import org.apache.hugegraph.entity.auth.UserView; import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.handler.ExceptionAdvisor; import org.apache.hugegraph.service.auth.AccessService; import org.apache.hugegraph.service.auth.BelongService; import org.apache.hugegraph.service.auth.GraphSpaceUserService; @@ -56,7 +59,7 @@ import org.apache.hugegraph.structure.auth.Target; import org.apache.hugegraph.structure.auth.User; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -65,12 +68,26 @@ public class GraphSpaceAuthOwnershipTest { private HugeClient client; private AuthManager auth; + private GraphSpaceManager graphSpace; + private GraphsManager graphs; @Before public void setup() { this.client = Mockito.mock(HugeClient.class); this.auth = Mockito.mock(AuthManager.class); + this.graphSpace = Mockito.mock(GraphSpaceManager.class); + this.graphs = Mockito.mock(GraphsManager.class); Mockito.when(this.client.auth()).thenReturn(this.auth); + Mockito.when(this.client.graphSpace()).thenReturn(this.graphSpace); + Mockito.when(this.client.graphs()).thenReturn(this.graphs); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(this.graphs.listGraph()).thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceAdmin(Mockito.anyString())) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceMember(Mockito.anyString())) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSuperAdmin()) + .thenReturn(Collections.emptyList()); } @Test @@ -451,8 +468,10 @@ public void testBelongBatchDeleteValidatesAllBeforeMutation() { public void testGraphSpaceUserRemovalDeletesOnlyScopedBelongs() { BelongService belongs = Mockito.mock(BelongService.class); User account = new User(); + account.setId("user-id"); account.name("graph-user"); Mockito.when(this.auth.getUser("user-id")).thenReturn(account); + Mockito.when(this.auth.listSpaceMember("SPACE_A")).thenReturn(Collections.singletonList("graph-user")); BelongEntity scoped = BelongEntity.builder() .id("belong-a") .userId("user-id") @@ -471,8 +490,34 @@ public void testGraphSpaceUserRemovalDeletesOnlyScopedBelongs() { Mockito.verify(this.auth).delSpaceMember("graph-user", "SPACE_A"); } + @Test + public void testReadOnlyPresetAppliesSpaceWideObserver() { + User account = new User(); + account.setId("graph-user"); + account.name("graph-user"); + Mockito.when(this.auth.getUser("graph-user")).thenReturn(account); + BelongService belongs = Mockito.mock(BelongService.class); + Mockito.when(belongs.list(this.client, "SPACE_A", null, + "graph-user")) + .thenReturn(Collections.emptyList()); + GraphSpaceUserService service = new GraphSpaceUserService(); + 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"); + Mockito.verify(this.graphSpace).setDefaultRole( + "SPACE_A", "graph-user", "observer"); + Mockito.verify(this.graphSpace, Mockito.never()).setDefaultRole( + Mockito.anyString(), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString()); + } + @Test public void testGraphSpaceUserRoleUpdatePreflightsAllRoles() { + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); List groups = this.createScopedGroups("SPACE_A", "SPACE_B"); Mockito.when(this.auth.getGraphSpaceGroup("local-role")) .thenReturn(groups.get(0)); @@ -496,10 +541,12 @@ public void testGraphSpaceUserRoleUpdatePreflightsAllRoles() { @Test public void testGraphSpaceUserCreationAddsPdMemberBeforeBelong() { + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); Group group = this.createScopedGroups("SPACE_A").get(0); Mockito.when(this.auth.getGraphSpaceGroup("local-role")) .thenReturn(group); User account = new User(); + account.setId("user-id"); account.name("graph-user"); Mockito.when(this.auth.getUser("user-id")).thenReturn(account); Mockito.when(this.auth.listSpaceMember("SPACE_A")) @@ -523,7 +570,7 @@ public void testGraphSpaceUserCreationAddsPdMemberBeforeBelong() { } @Test - public void testSpaceAdminAssignmentUsesPostOnly() throws Exception { + public void testOnlySuperAdminCanMutateSpaceAdmins() throws Exception { UserService authorization = Mockito.mock(UserService.class); Mockito.when(authorization.isAssignSpaceAdmin(this.client, "SPACE")) .thenReturn(true); @@ -531,19 +578,80 @@ public void testSpaceAdminAssignmentUsesPostOnly() throws Exception { new TestGraphSpaceUserController(this.client); setBaseUserService(controller, authorization); MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice( + new ExceptionAdvisor()) .build(); - mvc.perform(get("/api/v1.3/graphspaces/SPACE/auth/users/" + - "spaceadmin/user-id")) - .andExpect(status().isMethodNotAllowed()); - mvc.perform(put("/api/v1.3/graphspaces/SPACE/auth/users/" + - "spaceadmin/user-id") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isMethodNotAllowed()); mvc.perform(post("/api/v1.3/graphspaces/SPACE/auth/users/" + "spaceadmin/user-id") .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + mvc.perform(delete("/api/v1.3/graphspaces/SPACE/auth/users/" + + "spaceadmin/user-id")) + .andExpect(status().isForbidden()); + mvc.perform(put("/api/v1.3/graphspaces/SPACE/auth/users/user-id/preset") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"permission_preset\":\"GS_ADMIN\"}")) + .andExpect(status().isForbidden()); + } + + @Test + public void testSpaceAdminCanAssignPresetToNewMemberWithoutPreRead() + throws Exception { + UserService authorization = Mockito.mock(UserService.class); + Mockito.when(authorization.isAssignSpaceAdmin(this.client, "SPACE")) + .thenReturn(true); + GraphSpaceUserService members = + Mockito.mock(GraphSpaceUserService.class); + TestGraphSpaceUserController controller = + new TestGraphSpaceUserController(this.client); + setBaseUserService(controller, authorization); + ReflectionTestUtils.setField(controller, "userService", members); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice( + new ExceptionAdvisor()) + .build(); + + mvc.perform(put("/api/v1.3/graphspaces/SPACE/auth/users/" + + "new-member/preset") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"new-member\"," + + "\"permission_preset\":\"GS_READ_WRITE\"}")) .andExpect(status().isOk()); + + Mockito.verify(members).applySpacePreset( + this.client, "SPACE", null, "new-member", "GS_READ_WRITE"); + Mockito.verify(this.auth, Mockito.never()) + .getUser(Mockito.anyString()); + } + + @Test + public void testSpaceAdminCannotChangeGlobalAdminPreset() + throws Exception { + Mockito.when(this.auth.listSuperAdmin()) + .thenReturn(Collections.singletonList("global-admin")); + UserService authorization = Mockito.mock(UserService.class); + Mockito.when(authorization.isAssignSpaceAdmin(this.client, "SPACE")) + .thenReturn(true); + GraphSpaceUserService members = + Mockito.mock(GraphSpaceUserService.class); + TestGraphSpaceUserController controller = + new TestGraphSpaceUserController(this.client); + setBaseUserService(controller, authorization); + ReflectionTestUtils.setField(controller, "userService", members); + MockMvc mvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice( + new ExceptionAdvisor()) + .build(); + + mvc.perform(put("/api/v1.3/graphspaces/SPACE/auth/users/" + + "global-admin/preset") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"global-admin\"," + + "\"permission_preset\":\"GS_READ_WRITE\"}")) + .andExpect(status().isForbidden()); + + Mockito.verifyZeroInteractions(members); } private static Target target(String id, String graphSpace) { diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/ingest/IngestControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/ingest/IngestControllerTest.java index 639bc341b..e1c9841db 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/ingest/IngestControllerTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/ingest/IngestControllerTest.java @@ -51,10 +51,12 @@ import org.apache.hugegraph.entity.load.LoadTask; import org.apache.hugegraph.exception.ExternalException; import org.apache.hugegraph.options.HubbleOptions; +import org.apache.hugegraph.service.auth.AuthModeService; import org.apache.hugegraph.service.load.DatasourceService; import org.apache.hugegraph.service.load.FileMappingService; import org.apache.hugegraph.service.load.JobManagerService; import org.apache.hugegraph.service.load.LoadTaskService; +import org.apache.hugegraph.service.space.GraphSpaceService; import org.apache.hugegraph.testutil.Assert; public class IngestControllerTest { @@ -335,7 +337,15 @@ public void testJobListRateDoesNotBecomeInfinityForSubSecondTask() throws Exception { TestIngestController controller = new TestIngestController(); LoadTaskService loadTaskService = Mockito.mock(LoadTaskService.class); + JobManagerService jobManagerService = + Mockito.mock(JobManagerService.class); this.setField(controller, "loadTaskService", loadTaskService); + this.setField(controller, "jobManagerService", jobManagerService); + Mockito.when(jobManagerService.get(7)) + .thenReturn(JobManager.builder() + .graphSpace("DEFAULT") + .graph("hugegraph") + .build()); LoadTask task = LoadTask.builder() .id(9) @@ -362,6 +372,99 @@ public void testJobListRateDoesNotBecomeInfinityForSubSecondTask() Assert.assertEquals(63L, metrics.totalTime); } + @Test + public void testTaskListFiltersProtectedGraphSpacesInAnonymousMode() + throws Exception { + TestIngestController controller = new TestIngestController(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)) + .thenReturn(false); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)) + .thenReturn(true); + GraphSpaceService graphSpaces = Mockito.mock(GraphSpaceService.class); + JobManagerService jobs = Mockito.mock(JobManagerService.class); + LoadTaskService loadTasks = Mockito.mock(LoadTaskService.class); + JobManager visible = JobManager.builder() + .id(1) + .jobName("visible") + .graphSpace("public") + .graph("graph") + .jobStatus(JobStatus.DEFAULT) + .build(); + com.baomidou.mybatisplus.extension.plugins.pagination.Page< + JobManager> visiblePage = + new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>( + 1, 10, 1); + visiblePage.setRecords(Collections.singletonList(visible)); + Mockito.when(jobs.listByGraphSpaces( + Collections.singleton("public"), 1, 10, "")) + .thenReturn(visiblePage); + Mockito.when(graphSpaces.listAnonymous(Mockito.any())) + .thenReturn(Collections.singletonList("public")); + this.setField(controller, "config", config); + this.setField(controller, "authMode", new AuthModeService(config)); + this.setField(controller, "graphSpaceAccessService", graphSpaces); + this.setField(controller, "jobManagerService", jobs); + this.setField(controller, "loadTaskService", loadTasks); + + Response response = controller.taskList("", 1, 10); + + @SuppressWarnings("unchecked") + com.baomidou.mybatisplus.core.metadata.IPage< + IngestController.TaskVO> page = + (com.baomidou.mybatisplus.core.metadata.IPage< + IngestController.TaskVO>) + response.getData(); + Assert.assertEquals(1L, page.getTotal()); + @SuppressWarnings("unchecked") + Map option = (Map) + page.getRecords().get(0).ingestionOption; + Assert.assertEquals("public", option.get("graphspace")); + Mockito.verify(loadTasks).taskListByJob(1); + Mockito.verify(jobs).listByGraphSpaces( + Collections.singleton("public"), 1, 10, ""); + } + + @Test + public void testTaskDetailValidatesOwningGraphSpace() throws Exception { + TestIngestController controller = new TestIngestController(); + JobManagerService jobs = Mockito.mock(JobManagerService.class); + JobManager job = JobManager.builder() + .id(7) + .graphSpace("protected") + .graph("graph") + .build(); + Mockito.when(jobs.get(7)).thenReturn(job); + this.setField(controller, "jobManagerService", jobs); + + Response response = controller.taskDetail(7); + + Assert.assertEquals(Constant.STATUS_OK, response.getStatus()); + Assert.assertEquals("protected", controller.checkedGraphSpace); + } + + @Test + public void testJobDetailValidatesParentGraphSpace() throws Exception { + TestIngestController controller = new TestIngestController(); + JobManagerService jobs = Mockito.mock(JobManagerService.class); + LoadTaskService loadTasks = Mockito.mock(LoadTaskService.class); + LoadTask task = LoadTask.builder().id(9).jobId(7).build(); + Mockito.when(loadTasks.get(9)).thenReturn(task); + Mockito.when(jobs.get(7)) + .thenReturn(JobManager.builder() + .id(7) + .graphSpace("protected") + .graph("graph") + .build()); + this.setField(controller, "jobManagerService", jobs); + this.setField(controller, "loadTaskService", loadTasks); + + Response response = controller.jobDetail(9); + + Assert.assertEquals(Constant.STATUS_OK, response.getStatus()); + Assert.assertEquals("protected", controller.checkedGraphSpace); + } + private IngestController.IngestTaskRequest request(Path dataFile) { IngestController.IngestTaskRequest request = new IngestController.IngestTaskRequest(); @@ -474,9 +577,24 @@ private void setField(Object object, String name, Object value) private static class TestIngestController extends IngestController { + private String checkedGraphSpace; + private String writeGraphSpace; + @Override protected HugeClient authClient(String graphSpace, String graph) { return Mockito.mock(HugeClient.class); } + + @Override + protected void requireGraphSpaceAccess(HugeClient client, + String graphSpace) { + this.checkedGraphSpace = graphSpace; + } + + @Override + protected HugeClient requireGraphSpaceWrite(String graphSpace) { + this.writeGraphSpace = graphSpace; + return Mockito.mock(HugeClient.class); + } } } 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..8280a659e 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.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; @@ -95,6 +96,7 @@ public void testPdDetailResponseNeverContainsDataPlaneSecrets() { HugeConfig config = Mockito.mock(HugeConfig.class); Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); controller.config = config; + ReflectionTestUtils.setField(controller, "userService", userService); ReflectionTestUtils.setField(controller, "graphSpaceService", service); @SuppressWarnings("unchecked") @@ -107,6 +109,98 @@ public void testPdDetailResponseNeverContainsDataPlaneSecrets() { 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 names = (Map) controller.list(); + @SuppressWarnings("unchecked") + Map detail = + (Map) controller.get("public"); + @SuppressWarnings("unchecked") + Map auth = + (Map) 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 testAuthenticatedEndpointsUseOnlyAccessibleGraphSpaces() { + HugeClient client = Mockito.mock(HugeClient.class); + UserService userService = Mockito.mock(UserService.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(userService.isSuperAdmin(client)).thenReturn(false); + Mockito.when(graphSpaceService.listAccessible(client)) + .thenReturn(java.util.Collections.singletonList("member")); + Mockito.when(graphSpaceService.isAuthForAccessible(client, "member")) + .thenReturn(true); + GraphSpaceEntity entity = new GraphSpaceEntity(); + entity.setName("member"); + Mockito.when(graphSpaceService.getAccessibleWithAdmins(client, + "member")) + .thenReturn(entity); + Mockito.when(graphSpaceService.toView(entity)) + .thenReturn(java.util.Collections.singletonMap("name", + "member")); + controller.config = config; + ReflectionTestUtils.setField(controller, "userService", userService); + ReflectionTestUtils.setField(controller, "graphSpaceService", + graphSpaceService); + + @SuppressWarnings("unchecked") + Map names = (Map) controller.list(); + @SuppressWarnings("unchecked") + Map detail = + (Map) controller.get("member"); + @SuppressWarnings("unchecked") + Map auth = + (Map) controller.isAuth("member"); + + Assert.assertEquals(java.util.Collections.singletonList("member"), + names.get("graphspaces")); + Assert.assertEquals("member", detail.get("name")); + Assert.assertEquals(true, auth.get("auth")); + Mockito.verify(graphSpaceService, Mockito.never()).listAll(client); + Mockito.verify(graphSpaceService, Mockito.never()) + .isAuth(client, "member"); + Mockito.verify(graphSpaceService, Mockito.never()) + .getWithAdmins(client, "member"); + } + @Test public void testApplyDefaultsForOptionalResourceLimits() { GraphSpaceEntity graphSpace = new GraphSpaceEntity(); @@ -166,6 +260,25 @@ public void testOnlySuperadminCanMutateGraphSpaces() throws Exception { Assert.assertSame(client, controller.requireGlobalManager()); } + @Test + public void testAnonymousModeCannotMutateGraphSpaces() throws Exception { + HugeClient client = Mockito.mock(HugeClient.class); + UserService userService = Mockito.mock(UserService.class); + GraphSpaceService graphSpaceService = Mockito.mock( + GraphSpaceService.class); + TestGraphSpaceController controller = controller( + client, userService, + graphSpaceService); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)).thenReturn(false); + ReflectionTestUtils.setField(controller, "authMode", + new AuthModeService(config)); + + assertForbidden(() -> controller.add(new GraphSpaceEntity())); + + Mockito.verifyZeroInteractions(userService, graphSpaceService); + } + @Test public void testForbiddenGraphSpaceMutationUsesHttpAndBody403() throws Exception { 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 new file mode 100644 index 000000000..aa31149ad --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/controller/space/SchemaTemplateControllerSecurityTest.java @@ -0,0 +1,186 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.controller.space; + +import java.util.Collections; + +import org.junit.Test; +import org.mockito.Mockito; + +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; + +public class SchemaTemplateControllerSecurityTest { + + @Test + public void testCreateRequiresGraphSpaceWrite() { + TestController controller = controller(true); + controller.denyWrite(); + + Assert.assertThrows(ForbiddenException.class, () -> + controller.create("space", Mockito.mock(SchemaTemplate.class))); + } + + @Test + public void testUpdateRequiresGraphSpaceWrite() { + TestController controller = controller(true); + controller.denyWrite(); + + Assert.assertThrows(ForbiddenException.class, () -> + controller.update("space", "template", + Mockito.mock(SchemaTemplate.class))); + } + + @Test + public void testDeleteRequiresGraphSpaceWrite() { + 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()); + } + + @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) { + 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 d5a68defb..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 @@ -18,8 +18,10 @@ package org.apache.hugegraph.service.auth; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -32,6 +34,7 @@ import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.auth.UserEntity; +import org.apache.hugegraph.exception.ForbiddenException; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.testutil.Assert; @@ -42,6 +45,7 @@ public void testPdSuperAdminGetsGlobalActionsAndOperations() throws Exception { Fixture fixture = new Fixture(true); UserEntity user = user(true, Arrays.asList("space-b", "space-a")); user.setPassword("password-canary"); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(true); Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) .thenReturn(user); @@ -53,6 +57,8 @@ public void testPdSuperAdminGetsGlobalActionsAndOperations() throws Exception { Assert.assertEquals("SUPERADMIN", context.get("role")); Assert.assertEquals("alice", context.get("username")); Assert.assertTrue(capabilities(context).contains("accounts_manage")); + Assert.assertTrue(capabilities(context).contains( + "account_permission_presets")); Assert.assertTrue(capabilities(context).contains("graphspaces_manage")); Assert.assertTrue(capabilities(context).contains( "operations_metrics_read")); @@ -72,6 +78,7 @@ public void testPdSuperAdminGetsGlobalActionsAndOperations() throws Exception { @Test public void testPdSpaceAdminOnlyGetsScopedManagementActions() { Fixture fixture = new Fixture(true); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(true); Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) .thenReturn(user(false, Arrays.asList("space-b", "space-a", "space-a"))); @@ -86,18 +93,51 @@ public void testPdSpaceAdminOnlyGetsScopedManagementActions() { Assert.assertTrue(capabilities(context).contains( "graphspace_members_manage")); Assert.assertTrue(actions(context, "members").contains("add")); - Assert.assertTrue(actions(context, "roles").contains("update")); - Assert.assertTrue(actions(context, "authorizations").contains("grant")); + Assert.assertTrue(actions(context, "roles").isEmpty()); + Assert.assertTrue(actions(context, "authorizations").isEmpty()); + Assert.assertFalse(capabilities(context).contains("graphspace_roles_manage")); + Assert.assertFalse(capabilities(context).contains("graphspace_authorizations_manage")); Assert.assertEquals(Arrays.asList("space-a", "space-b"), scopes(context).get("admin_graphspaces")); + Assert.assertEquals(Arrays.asList("space-a", "space-b"), + scopes(context).get("write_graphspaces")); Assert.assertFalse((Boolean) scopes(context).get("all_graphspaces")); } + @Test + 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"))); + + Map context = fixture.service.context(fixture.client, + "alice"); + + Assert.assertFalse(capabilities(context).contains( + "account_permission_presets")); + Assert.assertFalse(capabilities(context).contains( + "graphspace_members_manage")); + 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 public void testPdUserOnlyGetsSelfActions() { Fixture fixture = new Fixture(true); + UserEntity user = user(false, Collections.emptyList()); + user.setGraphspacePermissions(Arrays.asList( + permission("space-c", "GS_READ_ONLY"), + permission("space-b", "GS_READ_WRITE"), + permission("space-a", "GS_READ_WRITE"))); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(true); Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) - .thenReturn(user(false, Collections.emptyList())); + .thenReturn(user); Map context = fixture.service.context(fixture.client, "alice"); @@ -105,12 +145,90 @@ public void testPdUserOnlyGetsSelfActions() { Assert.assertEquals("USER", context.get("role")); Assert.assertEquals(Set.of("account_self_manage", "graph_resources_access", - "graphspaces_read"), + "graphspaces_read", + "account_permission_presets"), capabilities(context)); Assert.assertEquals(Set.of("read", "update", "change_password"), actions(context, "account")); Assert.assertTrue(actions(context, "accounts").isEmpty()); Assert.assertTrue(actions(context, "members").isEmpty()); + Assert.assertEquals(Arrays.asList("space-a", "space-b"), + scopes(context).get("write_graphspaces")); + } + + @Test + public void testPdReadOnlyUserGetsNoWriteScope() { + Fixture fixture = new Fixture(true); + UserEntity user = user(false, Collections.emptyList()); + user.setGraphspacePermissions(Collections.singletonList( + permission("space-a", "GS_READ_ONLY"))); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user); + + Map context = fixture.service.context(fixture.client, + "alice"); + + Assert.assertEquals(Collections.emptyList(), + scopes(context).get("write_graphspaces")); + } + + @Test + public void testPdReadOnlyUserCannotWriteGraphSpaceResources() { + Fixture fixture = new Fixture(true); + UserEntity user = user(false, Collections.emptyList()); + user.setGraphspacePermissions(Collections.singletonList( + permission("space-a", "GS_READ_ONLY"))); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user); + + Assert.assertThrows( + ForbiddenException.class, + () -> fixture.service.requireGraphSpaceWrite( + fixture.client, "alice", "space-a")); + } + + @Test + public void testPdReadWriteUserCanWriteGraphSpaceResources() { + Fixture fixture = new Fixture(true); + UserEntity user = user(false, Collections.emptyList()); + user.setGraphspacePermissions(Collections.singletonList( + permission("space-a", "GS_READ_WRITE"))); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user); + + fixture.service.requireGraphSpaceWrite(fixture.client, "alice", + "space-a"); + } + + @Test + public void testLegacyPdDefersGraphSpaceWritesToServerAuthorization() { + Fixture fixture = new Fixture(true); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(false); + + fixture.service.requireGraphSpaceWrite(fixture.client, "alice", + "space-a"); + + Mockito.verify(fixture.users, Mockito.never()) + .getpersonal(Mockito.any(), Mockito.anyString()); + } + + @Test + public void testLegacyPdAnalystKeepsWriteScope() { + Fixture fixture = new Fixture(true); + UserEntity user = user(false, Collections.emptyList()); + user.setResSpaces(Arrays.asList("space-b", "space-a")); + Mockito.when(fixture.client.supportsDefaultRole()).thenReturn(false); + Mockito.when(fixture.users.getpersonal(fixture.client, "alice")) + .thenReturn(user); + + Map context = fixture.service.context(fixture.client, + "alice"); + + Assert.assertEquals(Arrays.asList("space-a", "space-b"), + scopes(context).get("write_graphspaces")); } @Test @@ -135,6 +253,31 @@ public void testNonPdAdminMapsToCanonicalSuperAdminWithoutPdActions() { .getpersonal(Mockito.any(), Mockito.anyString()); } + @Test + public void testAnonymousModeGetsReadOnlyOperationsCapabilities() { + Fixture fixture = new Fixture(true); + Mockito.when(fixture.config.get(HubbleOptions.AUTH_ENABLED)) + .thenReturn(false); + + Map context = fixture.service.context(fixture.client, + null); + + Assert.assertEquals("NON_AUTH", context.get("mode")); + Assert.assertEquals("ANONYMOUS", context.get("role")); + Assert.assertEquals("anonymous-v2-pd", + context.get("context_version")); + Assert.assertTrue(capabilities(context).contains( + "operations_health_read")); + Assert.assertTrue(capabilities(context).contains( + "operations_topology_read")); + Assert.assertTrue(capabilities(context).contains( + "operations_metrics_read")); + Assert.assertEquals(Set.of("read_health", "read_topology", + "read_metrics"), + actions(context, "operations")); + Mockito.verifyZeroInteractions(fixture.users); + } + @Test public void testContextVersionIsStableButChangesWithScope() { Fixture fixture = new Fixture(true); @@ -163,9 +306,18 @@ private static UserEntity user(boolean superadmin, user.setName("alice"); user.setSuperadmin(superadmin); user.setAdminSpaces(adminSpaces); + user.setResSpaces(new ArrayList<>()); return user; } + private static Map permission(String graphspace, + String preset) { + Map permission = new HashMap<>(); + permission.put("graphspace", graphspace); + permission.put("permission_preset", preset); + return permission; + } + @SuppressWarnings("unchecked") private static Set capabilities(Map context) { return (Set) context.get("capabilities"); @@ -194,6 +346,8 @@ private static class Fixture { 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 new file mode 100644 index 000000000..6feee6e3a --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java @@ -0,0 +1,705 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.service.auth; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import org.apache.hugegraph.driver.AuthManager; +import org.apache.hugegraph.driver.GraphSpaceManager; +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.auth.BelongEntity; +import org.apache.hugegraph.entity.auth.RoleEntity; +import org.apache.hugegraph.entity.auth.UserView; +import org.apache.hugegraph.exception.ParameterizedException; +import org.apache.hugegraph.structure.auth.User; +import org.apache.hugegraph.testutil.Assert; + +public class GraphSpaceUserServiceTest { + + private HugeClient client; + private AuthManager auth; + private GraphSpaceManager graphSpace; + private BelongService belongService; + private GraphSpaceUserService service; + + @Before + public void setup() { + this.client = Mockito.mock(HugeClient.class); + this.auth = Mockito.mock(AuthManager.class); + this.graphSpace = Mockito.mock(GraphSpaceManager.class); + this.belongService = Mockito.mock(BelongService.class); + this.service = new GraphSpaceUserService(); + ReflectionTestUtils.setField(this.service, "belongService", + this.belongService); + Mockito.when(this.client.auth()).thenReturn(this.auth); + Mockito.when(this.client.graphSpace()).thenReturn(this.graphSpace); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(this.auth.listSuperAdmin()) + .thenReturn(Collections.emptyList()); + } + + @Test + public void testPermissionPresetFailureHasActionableErrorKey() { + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + + ParameterizedException error = null; + try { + this.service.validatePermissionPresets( + this.client, Collections.emptyList(), "GS_READ_ONLY"); + } catch (ParameterizedException e) { + error = e; + } + + Assert.assertNotNull(error); + Assert.assertEquals("auth.permission-preset.unsupported", + 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 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"); + Mockito.when(this.belongService.list( + this.client, "team", null, null)) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.client.findUserByName("alice")).thenReturn(user); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "observer")) + .thenReturn(true); + + UserView view = this.service.listUsers(this.client, "team").get(0); + + Assert.assertEquals(1, view.getRoles().size()); + Assert.assertEquals("observer", view.getRoles().get(0).getName()); + Assert.assertEquals("GS_READ_ONLY", + view.getRoles().get(0).getPermissionPreset()); + } + + @Test + public void testLegacyAccessUsesMembershipWithoutDefaultRoleApi() { + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.singletonList("alice")); + + Assert.assertTrue(this.service.hasGraphSpaceAccess( + this.client, "team", "alice")); + Assert.assertFalse(this.service.hasGraphSpaceAccess( + this.client, "team", "bob")); + Mockito.verifyZeroInteractions(this.graphSpace); + Mockito.verify(this.auth, Mockito.times(2)).listSpaceMember("team"); + } + + @Test + public void testModernRolePayloadRequiresPresetApi() { + UserView user = new UserView( + "u-1", "alice", + Collections.singletonList(new RoleEntity("custom", "custom"))); + + ParameterizedException error = null; + try { + this.service.createOrUpdate(this.client, "team", user); + } catch (ParameterizedException e) { + error = e; + } + + Assert.assertNotNull(error); + Assert.assertEquals("auth.permission-preset.required", + error.getMessage()); + Mockito.verifyZeroInteractions(this.belongService); + Mockito.verifyZeroInteractions(this.auth); + } + + @Test + public void testGetUserMergesCustomAndExplicitDefaultRoles() { + User user = user("u-1", "alice"); + BelongEntity custom = BelongEntity.builder() + .userId("u-1") + .userName("alice") + .roleId("custom-id") + .roleName("analyst") + .build(); + Mockito.when(this.belongService.list( + this.client, "team", null, "u-1")) + .thenReturn(Collections.singletonList(custom)); + Mockito.when(this.auth.getUser("u-1")).thenReturn(user); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "observer")) + .thenReturn(true); + + UserView view = this.service.getUser(this.client, "team", "u-1"); + + Assert.assertEquals(2, view.getRoles().size()); + RoleEntity customRole = view.getRoles().get(0); + RoleEntity defaultRole = view.getRoles().get(1); + Assert.assertNull(customRole.getPermissionPreset()); + Assert.assertEquals("GS_READ_ONLY", + defaultRole.getPermissionPreset()); + } + + @Test + public void testApplyReadOnlyPresetAndRemoveUnrequestedSpace() { + User user = user("alice", "alice"); + Mockito.when(this.client.findUserByName("alice")).thenReturn(user); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.graphSpace.listGraphSpace()) + .thenReturn(java.util.Arrays.asList("team", "old")); + Mockito.when(this.belongService.list( + Mockito.eq(this.client), Mockito.anyString(), + Mockito.isNull(), Mockito.eq("alice"))) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceMember("old")) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.listSpaceAdmin(Mockito.anyString())) + .thenReturn(Collections.emptyList()); + + this.service.applyPermissionPresets( + this.client, "alice", + Collections.singletonList(permission("team", "GS_READ_ONLY")), + "GS_READ_ONLY"); + + Mockito.verify(this.auth).addSpaceMember("alice", "team"); + Mockito.verify(this.graphSpace) + .setDefaultRole("team", "alice", "observer"); + 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"); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "alice")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.emptyList()); + + this.service.applySpacePreset(this.client, "team", "alice", + "alice", + "GS_ADMIN"); + + Mockito.verify(this.auth).addSpaceMember("alice", "team"); + Mockito.verify(this.auth).addSpaceAdmin("alice", "team"); + Mockito.verify(this.graphSpace) + .setDefaultRole("team", "alice", "analyst"); + } + + @Test + public void testApplyReadWritePresetRemovesAdminAccess() { + User user = user("alice", "alice"); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "alice")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.singletonList("alice")); + + this.service.applySpacePreset(this.client, "team", "alice", + "alice", + "GS_READ_WRITE"); + + Mockito.verify(this.auth).delSpaceAdmin("alice", "team"); + Mockito.verify(this.graphSpace) + .setDefaultRole("team", "alice", "analyst"); + Mockito.verify(this.auth, Mockito.never()) + .addSpaceMember(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void testApplyPresetAddsMemberBeforeScopedUserRead() { + User user = user("alice", "alice"); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "alice")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.emptyList()); + + this.service.applySpacePreset(this.client, "team", "alice", + "alice", + "GS_READ_ONLY"); + + InOrder order = Mockito.inOrder(this.auth); + order.verify(this.auth).addSpaceMember("alice", "team"); + order.verify(this.auth).getUser("alice"); + Mockito.verify(this.graphSpace) + .setDefaultRole("team", "alice", "observer"); + } + + @Test + public void testApplyPresetRollsBackBootstrapMemberOnUserReadFailure() { + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.getUser("alice")) + .thenThrow(new RuntimeException("read failed")); + + Assert.assertThrows(RuntimeException.class, + () -> this.service.applySpacePreset( + this.client, "team", "alice", + "alice", + "GS_READ_ONLY")); + + InOrder order = Mockito.inOrder(this.auth); + order.verify(this.auth).addSpaceMember("alice", "team"); + order.verify(this.auth).getUser("alice"); + order.verify(this.auth).delSpaceMember("alice", "team"); + } + + @Test + public void testApplyPresetReconcilesMemberAfterLostAddResponse() { + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.addSpaceMember("alice", "team")) + .thenThrow(new RuntimeException("response lost")); + + Assert.assertThrows(RuntimeException.class, + () -> this.service.applySpacePreset( + this.client, "team", "alice", + "alice", + "GS_READ_ONLY")); + + Mockito.verify(this.auth).delSpaceMember("alice", "team"); + Mockito.verify(this.auth, Mockito.never()).getUser("alice"); + } + + @Test + public void testApplyPresetRollsBackMemberOnDefaultRoleFailure() { + User user = user("alice", "alice"); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "alice")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpace.setDefaultRole( + "team", "alice", "observer")) + .thenThrow(new RuntimeException("role failed")); + + Assert.assertThrows(RuntimeException.class, + () -> this.service.applySpacePreset( + this.client, "team", "alice", + "alice", + "GS_READ_ONLY")); + + InOrder order = Mockito.inOrder(this.auth, this.graphSpace); + order.verify(this.auth).addSpaceMember("alice", "team"); + order.verify(this.auth).getUser("alice"); + order.verify(this.graphSpace) + .setDefaultRole("team", "alice", "observer"); + order.verify(this.auth).delSpaceMember("alice", "team"); + } + + @Test + public void testApplyAdminPresetRollsBackAdminAndMemberOnRoleFailure() { + User user = user("alice", "alice"); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "alice")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.graphSpace.setDefaultRole( + "team", "alice", "analyst")) + .thenThrow(new RuntimeException("role failed")); + + Assert.assertThrows(RuntimeException.class, + () -> this.service.applySpacePreset( + this.client, "team", "alice", + "alice", + "GS_ADMIN")); + + InOrder order = Mockito.inOrder(this.auth, this.graphSpace); + order.verify(this.auth).addSpaceMember("alice", "team"); + order.verify(this.auth).getUser("alice"); + order.verify(this.auth).addSpaceAdmin("alice", "team"); + order.verify(this.graphSpace) + .setDefaultRole("team", "alice", "analyst"); + order.verify(this.auth).delSpaceAdmin("alice", "team"); + order.verify(this.auth).delSpaceMember("alice", "team"); + } + + @Test + public void testApplyPresetRestoresExistingAdminStateOnFailure() { + User user = user("alice", "alice"); + BelongEntity custom = BelongEntity.builder() + .id("belong-1") + .userId("alice") + .roleId("role-1") + .build(); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "alice")) + .thenReturn(Collections.singletonList(custom)) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "analyst")) + .thenReturn(true, false); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "observer")) + .thenReturn(false); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.singletonList("alice")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpace.setDefaultRole( + "team", "alice", "analyst")) + .thenThrow(new RuntimeException("role failed")) + .thenReturn(Collections.emptyMap()); + + Assert.assertThrows(RuntimeException.class, + () -> this.service.applySpacePreset( + this.client, "team", "alice", + "alice", + "GS_READ_WRITE")); + + Mockito.verify(this.belongService) + .add(this.client, "team", "role-1", "alice"); + Mockito.verify(this.graphSpace, Mockito.times(2)) + .setDefaultRole("team", "alice", "analyst"); + Mockito.verify(this.auth).addSpaceAdmin("alice", "team"); + Mockito.verify(this.auth, Mockito.never()) + .delSpaceMember(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void testApplyPresetContinuesRestoringCustomRolesAfterFailure() { + User user = user("alice", "alice"); + BelongEntity first = BelongEntity.builder() + .id("belong-1") + .userId("alice") + .roleId("role-1") + .build(); + BelongEntity second = BelongEntity.builder() + .id("belong-2") + .userId("alice") + .roleId("role-2") + .build(); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "alice")) + .thenReturn(java.util.Arrays.asList(first, second)) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "analyst")) + .thenReturn(false); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "observer")) + .thenReturn(false); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpace.setDefaultRole( + "team", "alice", "observer")) + .thenThrow(new RuntimeException("role failed")); + Mockito.doThrow(new RuntimeException("first restore failed")) + .when(this.belongService) + .add(this.client, "team", "role-1", "alice"); + + Assert.assertThrows(RuntimeException.class, + () -> this.service.applySpacePreset( + this.client, "team", "alice", + "alice", + "GS_READ_ONLY")); + + Mockito.verify(this.belongService) + .add(this.client, "team", "role-1", "alice"); + Mockito.verify(this.belongService) + .add(this.client, "team", "role-2", "alice"); + } + + @Test + public void testApplyPresetRemovesRoleCommittedBeforeClientFailure() { + User user = user("alice", "alice"); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.emptyList()) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.getUser("alice")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "alice")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "analyst")) + .thenReturn(false); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "observer")) + .thenReturn(false, true); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpace.setDefaultRole( + "team", "alice", "observer")) + .thenThrow(new RuntimeException("response lost")); + + Assert.assertThrows(RuntimeException.class, + () -> this.service.applySpacePreset( + this.client, "team", "alice", + "alice", + "GS_READ_ONLY")); + + Mockito.verify(this.graphSpace) + .deleteDefaultRole("team", "alice", "observer"); + Mockito.verify(this.auth).delSpaceMember("alice", "team"); + } + + @Test + public void testRemovePresetCleansEveryGrantType() { + User user = user("u-1", "alice"); + BelongEntity belong = BelongEntity.builder() + .id("belong-1") + .userId("u-1") + .build(); + Mockito.when(this.auth.getUser("u-1")).thenReturn(user); + Mockito.when(this.belongService.list( + this.client, "team", null, "u-1")) + .thenReturn(Collections.singletonList(belong)); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "analyst")) + .thenReturn(true); + Mockito.when(this.graphSpace.checkDefaultRole( + "team", "alice", "observer")) + .thenReturn(true); + Mockito.when(this.auth.listSpaceAdmin("team")) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.listSpaceMember("team")) + .thenReturn(Collections.singletonList("alice")); + + this.service.removeSpacePreset(this.client, "team", "u-1"); + + Mockito.verify(this.belongService) + .deleteById(this.client, "team", "belong-1"); + Mockito.verify(this.graphSpace) + .deleteDefaultRole("team", "alice", "analyst"); + Mockito.verify(this.graphSpace) + .deleteDefaultRole("team", "alice", "observer"); + Mockito.verify(this.auth).delSpaceAdmin("alice", "team"); + Mockito.verify(this.auth).delSpaceMember("alice", "team"); + } + + private static User user(String id, String name) { + User user = new User(); + user.setId(id); + user.name(name); + return user; + } + + private static Map permission(String graphSpace, + String preset) { + Map permission = new HashMap<>(); + permission.put("graphspace", graphSpace); + permission.put("permission_preset", preset); + return permission; + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java index 393b6c27b..1b7825418 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/DefaultOperationsDataServiceTest.java @@ -248,6 +248,56 @@ public void testPartialRefreshPreservesOnlyFailedSourceDataAsStale() { Assert.assertTrue(backend.isStale()); } + @SuppressWarnings("unchecked") + @Test + public void testPdFailureKeepsFreshStoreCapacityFacts() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> + calls.getAndIncrement() == 0 ? + factSnapshot("AVAILABLE", "AVAILABLE", 100L, 1000L) : + factSnapshot("UNAVAILABLE", "AVAILABLE", null, 2000L); + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ); + + service.overview(client("token-a"), capabilities, false); + Map result = service.overview(client("token-a"), + capabilities, true); + + Map facts = (Map) result.get("facts"); + Assert.assertEquals(Long.valueOf(100L), + facts.get("data_size_bytes")); + Assert.assertEquals(Long.valueOf(2000L), + facts.get("capacity_total_bytes")); + } + + @SuppressWarnings("unchecked") + @Test + public void testStoreFailureKeepsFreshPdFacts() { + AtomicInteger calls = new AtomicInteger(); + OperationsCollector collector = (client, metrics) -> + calls.getAndIncrement() == 0 ? + factSnapshot("AVAILABLE", "AVAILABLE", 100L, 1000L) : + factSnapshot("AVAILABLE", "UNAVAILABLE", 200L, null); + DefaultOperationsDataService service = new DefaultOperationsDataService( + collector, 5, CLOCK); + Set capabilities = Set.of( + OperationsCapabilityService.HEALTH_READ, + OperationsCapabilityService.TOPOLOGY_READ); + + service.overview(client("token-a"), capabilities, false); + Map result = service.overview(client("token-a"), + capabilities, true); + + Map facts = (Map) result.get("facts"); + Assert.assertEquals(Long.valueOf(200L), + facts.get("data_size_bytes")); + Assert.assertEquals(Long.valueOf(1000L), + facts.get("capacity_total_bytes")); + } + @Test public void testPartialRefreshReusesOnlyFailedMetricGroup() { AtomicInteger calls = new AtomicInteger(); @@ -499,6 +549,35 @@ private static Snapshot metricSnapshot(boolean partial) { Collections.emptyMap()); } + private static Snapshot factSnapshot(String pdAvailability, + String storesAvailability, + Long dataSize, Long capacity) { + Map sources = new LinkedHashMap<>(); + sources.put("pd", factSource(pdAvailability)); + sources.put("stores", factSource(storesAvailability)); + Map facts = new LinkedHashMap<>(); + if (dataSize != null) { + facts.put("data_size_bytes", dataSize); + } + if (capacity != null) { + facts.put("capacity_total_bytes", capacity); + facts.put("capacity_used_bytes", capacity / 2L); + } + String status = "AVAILABLE".equals(pdAvailability) && + "AVAILABLE".equals(storesAvailability) ? + "UP" : "DEGRADED"; + return new Snapshot(status, 2000L, false, null, sources, + Collections.emptyList(), facts); + } + + private static SourceStatus factSource(String availability) { + boolean available = "AVAILABLE".equals(availability); + return new SourceStatus(availability, available ? "UP" : "UNKNOWN", + 2000L, available ? 2000L : null, available, + false, available ? null : + "upstream_unavailable"); + } + private static SourceStatus available() { return new SourceStatus("AVAILABLE", "UP", 1000L, 1000L, true, false, null); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java index 9640678af..daef59055 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/LiveOperationsCollectorTest.java @@ -162,8 +162,7 @@ public void testPdStoresFailureKeepsServerAndClusterTopology() } @Test - public void testPdDegradedStatusMakesOverallSnapshotDegraded() - throws IOException { + public void testPdDegradedStatusMakesOverallSnapshotDegraded() throws IOException { String degraded = cluster().replace("Cluster_OK", "Cluster_Warn"); HttpServer pd = pdServer(200, degraded, 200, stores()); Snapshot snapshot; @@ -174,13 +173,36 @@ public void testPdDegradedStatusMakesOverallSnapshotDegraded() } Assert.assertEquals("DEGRADED", snapshot.getStatus()); - Assert.assertEquals("DEGRADED", - snapshot.getSources().get("pd").getStatus()); + Assert.assertEquals("DEGRADED", snapshot.getSources().get("pd").getStatus()); } @Test - public void testPdUnknownStatusMakesOverallSnapshotDegraded() - throws IOException { + public void testPdNotReadyKeepsPdNodesUpAndOverallDegraded() throws IOException { + String notReady = cluster().replace("Cluster_OK", "Cluster_Not_Ready"); + HttpServer pd = pdServer(200, notReady, 200, stores()); + Snapshot snapshot; + try { + snapshot = collector(true, pd).collect(serverClient(), false); + } finally { + pd.stop(0); + } + + OperationsModels.SourceStatus pdSource = snapshot.getSources().get("pd"); + Assert.assertEquals("AVAILABLE", pdSource.getAvailability()); + Assert.assertTrue(pdSource.isFresh()); + Assert.assertEquals("DEGRADED", pdSource.getStatus()); + Assert.assertEquals("DEGRADED", snapshot.getStatus()); + long pdCount = snapshot.getNodes().stream() + .filter(node -> "PD".equals(node.getType())) + .count(); + Assert.assertEquals(1L, pdCount); + Assert.assertTrue(snapshot.getNodes().stream() + .filter(node -> "PD".equals(node.getType())) + .allMatch(node -> "UP".equals(node.getStatus()))); + } + + @Test + public void testPdUnknownStatusMakesOverallSnapshotDegraded() throws IOException { String unknown = cluster().replace("Cluster_OK", "Cluster_Starting"); HttpServer pd = pdServer(200, unknown, 200, stores()); Snapshot snapshot; @@ -191,8 +213,18 @@ public void testPdUnknownStatusMakesOverallSnapshotDegraded() } Assert.assertEquals("DEGRADED", snapshot.getStatus()); - Assert.assertEquals("UNKNOWN", - snapshot.getSources().get("pd").getStatus()); + Assert.assertEquals("UNKNOWN", snapshot.getSources().get("pd").getStatus()); + } + + @Test + public void testUsesOnlyConfiguredPdServiceForClusterState() { + LeaderAwareHttpClient http = new LeaderAwareHttpClient("pd-1"); + LiveOperationsCollector collector = leaderAwareCollector(http); + + Snapshot snapshot = collector.collect(serverClient(), false); + + Assert.assertEquals("DEGRADED", snapshot.getSources().get("pd").getStatus()); + Assert.assertEquals(0, http.leaderRequests()); } @Test @@ -228,6 +260,10 @@ public void testStoreMetricsUseOnlyPdDiscoveredTarget() throws IOException { .get(group).getAvailability()); } Assert.assertEquals(Long.valueOf(2000L), store.getObservedAt()); + Assert.assertEquals(100L, + snapshot.getFacts().get("capacity_total_bytes")); + Assert.assertEquals(60L, + snapshot.getFacts().get("capacity_used_bytes")); Assert.assertFalse(store.getMetrics().toString().contains("secret")); OperationsModels.Node pdNode = snapshot.getNodes().stream() .filter(node -> "PD".equals(node.getType())) @@ -713,6 +749,15 @@ private static LiveOperationsCollector collector(RecordingHttpClient http, "http://[::1]:8520"))); } + private static LiveOperationsCollector leaderAwareCollector( + LeaderAwareHttpClient http) { + return new LiveOperationsCollector( + true, "http://pd-service:8620", "hubble", "secret", + "store-hubble", "store-secret", "server-under-test", http, + new OperationsPayloadParser(new ObjectMapper()), CLOCK, + 4, 1000, Collections.singleton("http://127.0.0.1:8520")); + } + private static HugeClient serverClient() { HugeClient client = Mockito.mock(HugeClient.class); VersionManager version = Mockito.mock(VersionManager.class); @@ -796,14 +841,14 @@ private static String cluster() { "\"pdLeader\":{\"restUrl\":\"http://pd:8620\"," + "\"state\":\"Up\",\"role\":\"Leader\"}," + "\"stores\":[{\"storeId\":1,\"state\":\"Up\"," + - "\"capacity\":100,\"available\":40}]}}"; + "\"partitionCount\":12}]}}"; } private static String stores() { return "{\"status\":0,\"data\":{\"stores\":[{" + "\"storeId\":\"1\",\"address\":\"127.0.0.1:8500\"," + "\"restAddress\":\"127.0.0.1:PD_TEST_PORT\"," + - "\"state\":\"Up\"}]}}"; + "\"state\":\"Up\",\"capacity\":100,\"available\":40}]}}"; } private static String storesWithoutRestAddress() { @@ -958,4 +1003,56 @@ private Set metricAuthorities() { return this.metricAuthorities; } } + + private static final class LeaderAwareHttpClient + extends OperationsHttpClient { + + private final String leaderHost; + private final AtomicInteger leaderRequests; + + private LeaderAwareHttpClient(String leaderHost) { + super(1000, 1000, 8192); + this.leaderHost = leaderHost; + this.leaderRequests = new AtomicInteger(); + } + + @Override + public String get(java.net.URI target, String username, String password, + Set allowedTargets) { + return this.response(target); + } + + @Override + public String get(java.net.URI target, String username, + String password) { + return this.response(target); + } + + private String response(java.net.URI target) { + if ("/v1/cluster".equals(target.getPath())) { + if (!"pd-service".equals(target.getHost())) { + this.leaderRequests.incrementAndGet(); + return leaderCluster("Cluster_OK", this.leaderHost); + } + return leaderCluster("Cluster_Not_Ready", this.leaderHost); + } + if ("/v1/stores".equals(target.getPath())) { + return stores(); + } + throw new AssertionError("Unexpected operations target " + target); + } + + private int leaderRequests() { + return this.leaderRequests.get(); + } + } + + private static String leaderCluster(String state, String leaderHost) { + return "{\"status\":0,\"data\":{\"state\":\"" + state + "\"," + + "\"graphSize\":2,\"pdList\":[{\"restUrl\":\"" + + leaderHost + ":8620\",\"state\":\"Up\"," + + "\"role\":\"Leader\"}],\"pdLeader\":{\"restUrl\":\"" + + leaderHost + ":8620\",\"state\":\"Up\"," + + "\"role\":\"Leader\"},\"stores\":[]}}"; + } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsPayloadParserTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsPayloadParserTest.java index 94ae333c0..de195a44d 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsPayloadParserTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/op/OperationsPayloadParserTest.java @@ -44,7 +44,7 @@ public void testParsesPdWrapperAndRedactsInfrastructureFields() "\"dataPath\":\"/secret/leader\",\"role\":\"Leader\"," + "\"serviceVersion\":\"1.7.0\"}," + "\"graphSize\":2,\"partitionSize\":12," + - "\"shardCount\":3}}"; + "\"shardCount\":3,\"dataSize\":5}}"; String stores = "{\"status\":0,\"data\":{\"stores\":[{" + "\"storeId\":\"7\",\"address\":\"store-a:8500\"," + "\"deployPath\":\"/secret/bin\"," + @@ -60,6 +60,12 @@ public void testParsesPdWrapperAndRedactsInfrastructureFields() Assert.assertEquals(2L, topology.getFacts().get("graphs")); Assert.assertEquals(12L, topology.getFacts().get("partitions")); Assert.assertEquals(3L, topology.getFacts().get("replicas")); + Assert.assertEquals(5120L, + topology.getFacts().get("data_size_bytes")); + Assert.assertEquals(1000L, + topology.getFacts().get("capacity_total_bytes")); + Assert.assertEquals(600L, + topology.getFacts().get("capacity_used_bytes")); List nodes = topology.getNodes(); Assert.assertEquals(3, nodes.size()); Assert.assertEquals("LEADER", nodes.get(1).getRole()); @@ -75,6 +81,44 @@ public void testParsesPdWrapperAndRedactsInfrastructureFields() Assert.assertFalse(serialized.contains("/secret")); } + @Test + public void testOmitsIncompleteOrOverflowingSizeFacts() { + String cluster = "{\"status\":0,\"data\":{" + + "\"pdList\":[],\"dataSize\":9223372036854775807}}"; + String stores = "{\"status\":0,\"data\":{\"stores\":[{" + + "\"storeId\":\"1\",\"capacity\":1000},{" + + "\"storeId\":\"2\",\"capacity\":1000," + + "\"available\":2000}]}}"; + + Topology topology = new OperationsPayloadParser(MAPPER) + .parseTopology(cluster, stores); + + Assert.assertFalse(topology.getFacts().containsKey("data_size_bytes")); + Assert.assertFalse(topology.getFacts().containsKey( + "capacity_total_bytes")); + Assert.assertFalse(topology.getFacts().containsKey( + "capacity_used_bytes")); + } + + @Test + public void testMapsExactClusterStates() { + OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); + String[] states = {"Cluster_OK", "Cluster_Warn", "Cluster_Not_Ready", + "Cluster_Offline", "Cluster_Fault", null, "Cluster_OKish"}; + String[] expected = {"UP", "DEGRADED", "DEGRADED", "DEGRADED", "DOWN", + "UNKNOWN", "UNKNOWN"}; + String stores = "{\"status\":0,\"data\":{\"stores\":[]}}"; + + for (int i = 0; i < states.length; i++) { + String state = states[i]; + String field = state == null ? "" : "\"state\":\"" + + state + "\","; + String cluster = "{\"status\":0,\"data\":{" + field + + "\"pdList\":[]}}"; + Assert.assertEquals(expected[i], parser.parseTopology(cluster, stores).getStatus()); + } + } + @Test public void testParsesOnlyStoreMetricTargetsAndInternalHostMap() { OperationsPayloadParser parser = new OperationsPayloadParser(MAPPER); 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 2f5958124..166aa67f7 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,12 +21,21 @@ 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; @@ -103,6 +112,280 @@ public void testPdListResponseNeverContainsDataPlaneSecrets() { Assert.assertFalse(response.get(0).containsKey("configs")); } + @Test + public void testAccessibleGraphSpacesHonorAuthorization() { + GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class); + AuthManager auth = Mockito.mock(AuthManager.class); + Map spaces = new HashMap<>(); + spaces.put("public", graphSpace("public", false, "20260712")); + spaces.put("admin", graphSpace("admin", true, "20260712")); + spaces.put("analyst", graphSpace("analyst", true, "20260712")); + spaces.put("observer", graphSpace("observer", true, "20260712")); + spaces.put("denied", graphSpace("denied", true, "20260712")); + + Mockito.when(this.client.graphSpace()).thenReturn(manager); + Mockito.when(this.client.auth()).thenReturn(auth); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(manager.listGraphSpace()) + .thenReturn(java.util.Arrays.asList("public", "admin", + "analyst", "observer", + "denied")); + Mockito.when(manager.getGraphSpace(Mockito.anyString())) + .thenAnswer(invocation -> spaces.get(invocation.getArgument(0))); + Mockito.when(auth.isSpaceAdmin("admin")).thenReturn(true); + Mockito.when(auth.checkDefaultRole("analyst", "analyst")) + .thenReturn(true); + Mockito.when(auth.checkDefaultRole("observer", "observer")) + .thenReturn(true); + Mockito.when(this.graphsService.listGraphNames( + Mockito.eq(this.client), Mockito.anyString(), + Mockito.eq(""))) + .thenReturn(java.util.Collections.emptySet()); + + List> response = + this.service.queryAccessibleGs(this.client, "", ""); + + Assert.assertEquals(java.util.Arrays.asList("admin", "analyst", + "observer", "public"), + response.stream().map(item -> item.get("name")) + .collect(java.util.stream.Collectors + .toList())); + Assert.assertTrue((Boolean) response.get(0).get("authed")); + Assert.assertFalse((Boolean) response.get(0).get("default")); + Assert.assertEquals(java.util.Arrays.asList( + "admin", "analyst", "observer", "public"), + this.service.listAccessible(this.client)); + Mockito.verify(auth, Mockito.never()) + .checkDefaultRole(Mockito.anyString(), Mockito.eq("observer"), + Mockito.anyString()); + } + + @Test + public void testLegacyMemberGraphSpaceRemainsVisible() { + GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class); + AuthManager auth = Mockito.mock(AuthManager.class); + GraphSpace space = graphSpace("legacy", true, "20260712"); + Mockito.when(this.client.graphSpace()).thenReturn(manager); + Mockito.when(this.client.auth()).thenReturn(auth); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + Mockito.when(manager.listGraphSpace()) + .thenReturn(java.util.Collections.singletonList("legacy")); + Mockito.when(manager.getGraphSpace("legacy")).thenReturn(space); + Mockito.when(auth.isSpaceMember("legacy")).thenReturn(true); + Mockito.when(this.graphsService.listGraphNames(this.client, "legacy", + "")) + .thenReturn(java.util.Collections.emptySet()); + + List> response = + this.service.queryAccessibleGs(this.client, "", ""); + + Assert.assertEquals(1, response.size()); + Assert.assertEquals("legacy", response.get(0).get("name")); + Mockito.verify(auth, Mockito.never()) + .checkDefaultRole(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void testAnonymousGraphSpacesApplyQueryAndTimeFilters() { + GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class); + GraphSpace visible = graphSpace("public", false, "20260712"); + visible.setNickname("visible space"); + GraphSpace old = graphSpace("visible-old", true, "20260701"); + Mockito.when(this.client.graphSpace()).thenReturn(manager); + Mockito.when(manager.listGraphSpace()) + .thenReturn(java.util.Arrays.asList("public", "visible-old")); + Mockito.when(manager.getGraphSpace("public")).thenReturn(visible); + Mockito.when(manager.getGraphSpace("visible-old")).thenReturn(old); + Mockito.when(this.graphsService.listGraphNames( + Mockito.eq(this.client), Mockito.anyString(), + Mockito.eq(""))) + .thenReturn(java.util.Collections.emptySet()); + + List> response = + this.service.queryAnonymousGs(this.client, "visible", + "20260702"); + + Assert.assertEquals(1, response.size()); + Assert.assertEquals("public", response.get(0).get("name")); + 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> 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 testAuthenticatedDetailRejectsUnassignedProtectedSpace() { + GraphSpaceManager manager = Mockito.mock(GraphSpaceManager.class); + AuthManager auth = Mockito.mock(AuthManager.class); + GraphSpace protectedSpace = graphSpace("protected", true, + "20260712"); + Mockito.when(this.client.graphSpace()).thenReturn(manager); + Mockito.when(this.client.auth()).thenReturn(auth); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + Mockito.when(manager.getGraphSpace("protected")) + .thenReturn(protectedSpace); + + try { + this.service.getAccessibleWithAdmins(this.client, "protected"); + Assert.fail("Expected protected GraphSpace to be unavailable"); + } catch (ExternalException e) { + Assert.assertEquals(404, e.status()); + } + try { + this.service.isAuthForAccessible(this.client, "protected"); + Assert.fail("Expected protected GraphSpace auth to be unavailable"); + } catch (ExternalException e) { + Assert.assertEquals(404, e.status()); + } + Mockito.verify(auth, Mockito.times(2)).isSpaceMember("protected"); + 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> 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> 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 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", "")) @@ -265,4 +548,12 @@ private static Map statistic(String date, Number vertex, statistic.put("edge", edge); return statistic; } + + private static GraphSpace graphSpace(String name, boolean auth, + String createTime) { + GraphSpace graphSpace = new GraphSpace(name); + graphSpace.setAuth(auth); + graphSpace.setCreateTime(createTime); + return graphSpace; + } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/AuthSecurityTest.java index 2675fc1a1..7152a304c 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 @@ -28,6 +28,7 @@ import java.net.InetSocketAddress; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -46,6 +47,9 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.servlet.handler.MappedInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.context.request.RequestContextHolder; @@ -55,6 +59,7 @@ import org.apache.hugegraph.common.Response; import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.config.IngestionProxyServlet; +import org.apache.hugegraph.config.WebMvcConfig; import org.apache.hugegraph.controller.BaseController; import org.apache.hugegraph.controller.auth.LoginController; import org.apache.hugegraph.driver.AuthManager; @@ -74,8 +79,10 @@ import org.apache.hugegraph.handler.MessageSourceHandler; import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.auth.AuthContextService; +import org.apache.hugegraph.service.auth.AuthModeService; import org.apache.hugegraph.service.auth.LoginAttemptGuard; import org.apache.hugegraph.service.auth.UserService; +import org.apache.hugegraph.service.space.GraphSpaceService; import org.apache.hugegraph.structure.auth.Login; import org.apache.hugegraph.structure.auth.LoginResult; @@ -177,6 +184,130 @@ public void testLoginInterceptorAllowsOptionsPreflight() { null)); } + @Test + public void testAnonymousModeBlocksAuthManagementButAllowsContext() { + LoginInterceptor interceptor = new LoginInterceptor(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)) + .thenReturn(false); + AuthModeService mode = new AuthModeService(config); + ReflectionTestUtils.setField(interceptor, "authMode", mode); + + MockHttpServletRequest users = new MockHttpServletRequest("GET", "/api/v1.3/auth/users"); + try { + interceptor.preHandle(users, new MockHttpServletResponse(), null); + Assert.fail("Expected anonymous auth management to be blocked"); + } catch (ExternalException forbidden) { + Assert.assertEquals(HttpStatus.FORBIDDEN.value(), + forbidden.status()); + } + + MockHttpServletRequest context = new MockHttpServletRequest("GET", "/api/v1.3/auth/context"); + Assert.assertTrue(interceptor.preHandle(context, new MockHttpServletResponse(), null)); + + MockHttpServletRequest scopedUsers = new MockHttpServletRequest( + "GET", "/api/v1.3/graphspaces/DEFAULT/auth/users"); + assertThrows(ExternalException.class, () -> + interceptor.preHandle(scopedUsers, + new MockHttpServletResponse(), null)); + + MockHttpServletRequest scopedStatus = new MockHttpServletRequest( + "GET", "/api/v1.3/graphspaces/DEFAULT/auth"); + Assert.assertTrue(interceptor.preHandle( + scopedStatus, 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 + public void testConfigBootstrapDoesNotCreateServerClient() + throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1.3/config"); + + Assert.assertTrue(interceptor.preHandle(request, new MockHttpServletResponse(), null)); + Assert.assertEquals(0, interceptor.authClients); + Assert.assertEquals(0, interceptor.unauthClients); + Assert.assertNull(request.getAttribute("hugeClient")); + } + + @Test + public void testAnonymousGraphClientRejectsProtectedGraphSpace() { + MockHttpServletRequest request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(request)); + TestBaseController controller = new TestBaseController(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)).thenReturn(false); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + GraphSpaceService spaces = Mockito.mock(GraphSpaceService.class); + HugeClient client = Mockito.mock(HugeClient.class); + Mockito.when(spaces.requirePublicSpace(client, "protected")) + .thenThrow(new ExternalException(HttpStatus.NOT_FOUND.value(), + "unavailable")); + ReflectionTestUtils.setField(controller, "config", config); + ReflectionTestUtils.setField(controller, "authMode", + new AuthModeService(config)); + ReflectionTestUtils.setField(controller, "graphSpaceAccessService", + spaces); + + try { + controller.requireSpace(client, "protected"); + Assert.fail("Expected protected GraphSpace to be unavailable"); + } catch (ExternalException e) { + Assert.assertEquals(HttpStatus.NOT_FOUND.value(), e.status()); + } + Mockito.verify(spaces).requirePublicSpace(client, "protected"); + } + + @Test + public void testBodyGraphSpaceIsValidatedWhenPathScopeDiffers() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(Constant.GRAPHSPACE_ACCESS_KEY, "path-space"); + RequestContextHolder.setRequestAttributes( + new ServletRequestAttributes(request)); + TestBaseController controller = new TestBaseController(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + GraphSpaceService spaces = Mockito.mock(GraphSpaceService.class); + HugeClient client = Mockito.mock(HugeClient.class); + ReflectionTestUtils.setField(controller, "config", config); + ReflectionTestUtils.setField(controller, "graphSpaceAccessService", + spaces); + + controller.requireSpace(client, "body-space"); + + Mockito.verify(spaces).requireAccessibleSpace(client, "body-space"); + } + + @Test + public void testOnlyBootstrapConfigBypassesLoginInterceptor() { + InterceptorRegistry registry = new InterceptorRegistry(); + new WebMvcConfig().addInterceptors(registry); + List interceptors = ReflectionTestUtils.invokeMethod( + registry, "getInterceptors"); + MappedInterceptor login = interceptors.stream() + .map(MappedInterceptor.class::cast) + .filter(interceptor -> interceptor.getInterceptor() + instanceof LoginInterceptor) + .findFirst() + .orElseThrow(AssertionError::new); + AntPathMatcher matcher = new AntPathMatcher(); + + Assert.assertFalse(login.matches( + Constant.API_VERSION + "config", matcher)); + Assert.assertTrue(login.matches( + Constant.API_VERSION + "setting/config", matcher)); + } + @Test public void testCustomInterceptorDoesNotCreateClientForMissingSession() throws Exception { @@ -265,6 +396,9 @@ public void testCustomInterceptorDoesNotCreateClientForOptions() "/api/v1.3/graphspaces/space1"); request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); request.getSession().setAttribute(Constant.USERNAME_KEY, "admin"); + request.getSession().setAttribute(Constant.PASSWORD_KEY, "secret"); + request.getSession().setAttribute(Constant.PASSWORD_EXPIRE_AT_KEY, + System.currentTimeMillis() + 10000L); Assert.assertTrue(interceptor.preHandle(request, new MockHttpServletResponse(), @@ -295,6 +429,12 @@ public void testCustomInterceptorKeepsUnauthClientForLogin() public void testCustomInterceptorCreatesClientForAuthenticatedApi() throws Exception { TestCustomInterceptor interceptor = new TestCustomInterceptor(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)) + .thenReturn(true); + GraphSpaceService spaces = Mockito.mock(GraphSpaceService.class); + ReflectionTestUtils.setField(interceptor, "config", config); + ReflectionTestUtils.setField(interceptor, "graphSpaceService", spaces); MockHttpServletRequest request = new MockHttpServletRequest( "GET", "/api/v1.3/graphspaces/space1" + @@ -311,6 +451,140 @@ public void testCustomInterceptorCreatesClientForAuthenticatedApi() Assert.assertEquals("space1", interceptor.graphSpace); Assert.assertEquals("graph1", interceptor.graph); Assert.assertEquals("token", interceptor.token); + Mockito.verify(spaces).requireAccessibleSpace(null, "space1"); + } + + @Test + public void testCustomInterceptorKeepsBearerForRestWithLegacyPassword() + throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/api/v1.3/auth/users/getpersonal"); + request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); + request.getSession().setAttribute(Constant.USERNAME_KEY, "admin"); + request.getSession().setAttribute(Constant.PASSWORD_KEY, "secret"); + request.getSession().setAttribute(Constant.PASSWORD_EXPIRE_AT_KEY, + System.currentTimeMillis() + 10000L); + + Assert.assertTrue(interceptor.preHandle(request, + new MockHttpServletResponse(), + null)); + + Assert.assertEquals(1, interceptor.authClients); + Assert.assertEquals("token", interceptor.token); + } + + @Test + public void testCustomInterceptorKeepsGraphCollectionActionsUnscoped() + throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + + for (String action : new String[]{"list", "default"}) { + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", "/api/v1.3/graphspaces/space1/graphs/" + action); + request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); + request.getSession().setAttribute(Constant.USERNAME_KEY, "user"); + + Assert.assertTrue(interceptor.preHandle( + request, new MockHttpServletResponse(), null)); + Assert.assertEquals("space1", interceptor.graphSpace); + Assert.assertNull(interceptor.graph); + } + Assert.assertEquals(2, interceptor.authClients); + } + + @Test + public void testCustomInterceptorKeepsGraphSpaceCollectionActionsUnscoped() + throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)) + .thenReturn(true); + GraphSpaceService spaces = Mockito.mock(GraphSpaceService.class); + ReflectionTestUtils.setField(interceptor, "config", config); + ReflectionTestUtils.setField(interceptor, "graphSpaceService", spaces); + + for (String action : new String[]{"list", "builtin"}) { + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", "/api/v1.3/graphspaces/" + action); + request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); + request.getSession().setAttribute(Constant.USERNAME_KEY, "admin"); + + Assert.assertTrue(interceptor.preHandle( + request, new MockHttpServletResponse(), null)); + Assert.assertNull(interceptor.graphSpace); + } + Mockito.verifyZeroInteractions(spaces); + } + + @Test + public void testCustomInterceptorAllowsGraphNamedLikeCollectionAction() + throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", "/api/v1.3/graphspaces/space1/graphs/list/schema"); + request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); + request.getSession().setAttribute(Constant.USERNAME_KEY, "user"); + + Assert.assertTrue(interceptor.preHandle( + request, new MockHttpServletResponse(), null)); + Assert.assertEquals("space1", interceptor.graphSpace); + Assert.assertEquals("list", interceptor.graph); + } + + @Test + public void testAnonymousClientUsesGraphSpaceScope() throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)) + .thenReturn(false); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)) + .thenReturn(true); + AuthModeService mode = new AuthModeService(config); + ReflectionTestUtils.setField(interceptor, "authMode", mode); + ReflectionTestUtils.setField(interceptor, "config", config); + GraphSpaceService spaces = Mockito.mock(GraphSpaceService.class); + ReflectionTestUtils.setField(interceptor, "graphSpaceService", spaces); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", "/api/v1.3/graphspaces/SPACE/graphs/graph/schema"); + + Assert.assertTrue(interceptor.preHandle(request, new MockHttpServletResponse(), null)); + Assert.assertEquals(1, interceptor.unauthClients); + Assert.assertEquals("SPACE", interceptor.graphSpace); + Assert.assertEquals("graph", interceptor.graph); + Mockito.verify(spaces).requirePublicSpace(null, "SPACE"); + } + + @Test + public void testAnonymousPathScopeRejectsProtectedGraphSpace() + throws Exception { + TestCustomInterceptor interceptor = new TestCustomInterceptor(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)) + .thenReturn(false); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)) + .thenReturn(true); + ReflectionTestUtils.setField(interceptor, "authMode", + new AuthModeService(config)); + ReflectionTestUtils.setField(interceptor, "config", config); + GraphSpaceService spaces = Mockito.mock(GraphSpaceService.class); + Mockito.doThrow(new ExternalException(HttpStatus.NOT_FOUND.value(), + "unavailable")) + .when(spaces).requirePublicSpace(null, "protected"); + ReflectionTestUtils.setField(interceptor, "graphSpaceService", spaces); + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", "/api/v1.3/graphspaces/protected/graphs/graph" + + "/job-manager/1/upload-file"); + + try { + interceptor.preHandle(request, new MockHttpServletResponse(), null); + Assert.fail("Expected protected GraphSpace to be unavailable"); + } catch (ExternalException e) { + Assert.assertEquals(HttpStatus.NOT_FOUND.value(), e.status()); + } + Assert.assertEquals(1, interceptor.unauthClients); + Mockito.verify(spaces).requirePublicSpace(null, "protected"); } @Test @@ -495,6 +769,9 @@ public void testClearAuthSessionClearsIdentityAndToken() { Assert.assertNull(request.getSession().getAttribute(Constant.TOKEN_KEY)); Assert.assertNull(request.getSession().getAttribute(Constant.USERNAME_KEY)); + Assert.assertNull(request.getSession().getAttribute(Constant.PASSWORD_KEY)); + Assert.assertNull(request.getSession().getAttribute( + Constant.PASSWORD_EXPIRE_AT_KEY)); } @Test @@ -564,9 +841,11 @@ public void testLoginCommitsAuthAndRotatesSessionAfterValidation() Constant.USERNAME_KEY)); Assert.assertEquals("server-token", request.getSession().getAttribute( Constant.TOKEN_KEY)); - Assert.assertNull(request.getSession().getAttribute("auth_password")); - Assert.assertNull(request.getSession().getAttribute( - "auth_password_expire_at")); + Assert.assertEquals("pa", request.getSession().getAttribute( + Constant.PASSWORD_KEY)); + Assert.assertTrue(((Number) request.getSession().getAttribute( + Constant.PASSWORD_EXPIRE_AT_KEY)).longValue() > + System.currentTimeMillis()); } @Test @@ -741,6 +1020,10 @@ private static class TestBaseController extends BaseController { public void clearAuth() { this.clearAuthSession(); } + + public void requireSpace(HugeClient client, String graphSpace) { + this.requireGraphSpaceAccess(client, graphSpace); + } } private static class TestLoginController extends LoginController { @@ -871,6 +1154,15 @@ protected org.apache.hugegraph.driver.HugeClient unauthClient() { this.unauthClients++; return null; } + + @Override + protected org.apache.hugegraph.driver.HugeClient unauthClient( + String graphSpace, String graph) { + this.unauthClients++; + this.graphSpace = graphSpace; + this.graph = graph; + return null; + } } private static void setField(Object object, String name, Object value) diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/BaseControllerGremlinClientTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/BaseControllerGremlinClientTest.java index 1e5a7c6a3..039321d01 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/BaseControllerGremlinClientTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/BaseControllerGremlinClientTest.java @@ -38,8 +38,9 @@ public void tearDown() { } @Test - public void testGremlinClientIgnoresLegacyPasswordAndUsesToken() { + public void testGremlinClientUsesLegacyBasicCredentials() { HugeClient tokenClient = Mockito.mock(HugeClient.class); + HugeClient basicClient = Mockito.mock(HugeClient.class); MockHttpServletRequest request = this.requestWithAuth(); request.setAttribute("hugeClient", tokenClient); @@ -48,15 +49,14 @@ public void testGremlinClientIgnoresLegacyPasswordAndUsesToken() { TestController controller = new TestController(); controller.authClient = tokenClient; + controller.basicClient = basicClient; HugeClient client = controller.gremlinClient("DEFAULT", "hugegraph"); - Assert.assertSame(tokenClient, client); - Assert.assertSame(tokenClient, request.getAttribute("hugeClient")); - Assert.assertTrue(controller.authClientCreated); - Assert.assertEquals("DEFAULT", controller.graphSpace); - Assert.assertEquals("hugegraph", controller.graph); - Mockito.verify(tokenClient, Mockito.never()).close(); + Assert.assertSame(basicClient, client); + Assert.assertSame(basicClient, request.getAttribute("hugeClient")); + Assert.assertFalse(controller.authClientCreated); + Mockito.verify(tokenClient).close(); } @Test @@ -132,6 +132,7 @@ private MockHttpServletRequest requestWithAuth() { private static class TestController extends BaseController { private HugeClient authClient; + private HugeClient basicClient; private boolean authClientCreated; private String graphSpace; private String graph; @@ -152,5 +153,17 @@ protected HugeClient authClient(String graphSpace, String graph) { this.getRequest().setAttribute("hugeClient", this.authClient); return this.authClient; } + + @Override + protected HugeClient createBasicClient(String graphSpace, String graph, + String username, String password) { + return this.basicClient; + } + + @Override + protected void requireGraphSpaceAccess(HugeClient client, + String graphSpace) { + // No graph-space service is needed for this client-selection test. + } } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java new file mode 100644 index 000000000..a60c457d9 --- /dev/null +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/ConfigControllerTest.java @@ -0,0 +1,49 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit; + +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.controller.ConfigController; +import org.apache.hugegraph.options.HubbleOptions; + +public class ConfigControllerTest { + + @Test + public void testBootstrapConfigDoesNotExposeBackendUrl() { + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)).thenReturn(true); + + ConfigController controller = new ConfigController(); + ReflectionTestUtils.setField(controller, "config", config); + + Map result = controller.getConfig(); + + Assert.assertEquals(Map.of("pd_enabled", false, + "auth_enabled", true), result); + Mockito.verify(config, Mockito.never()).get(HubbleOptions.SERVER_URL); + } +} diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingDeletionTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingDeletionTest.java index 1656cf852..4c5035473 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingDeletionTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/FileMappingDeletionTest.java @@ -25,6 +25,7 @@ import org.mockito.Mockito; import org.apache.hugegraph.controller.load.FileMappingController; +import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.enums.LoadStatus; import org.apache.hugegraph.entity.load.FileMapping; import org.apache.hugegraph.entity.load.LoadTask; @@ -70,7 +71,7 @@ public void testDeleteAllowsTerminalTaskAndUsesTransactionalService() private Fixture fixture(LoadStatus status) throws Exception { Fixture fixture = new Fixture(); - fixture.controller = new FileMappingController(); + fixture.controller = new TestFileMappingController(); fixture.mappingService = Mockito.mock(FileMappingService.class); fixture.jobService = Mockito.mock(JobManagerService.class); LoadTaskService taskService = Mockito.mock(LoadTaskService.class); @@ -95,9 +96,27 @@ private Fixture fixture(LoadStatus status) throws Exception { private static void setField(Object target, String name, Object value) throws Exception { - Field field = target.getClass().getDeclaredField(name); - field.setAccessible(true); - field.set(target, value); + Class type = target.getClass(); + while (type != null) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } + + private static class TestFileMappingController + extends FileMappingController { + + @Override + protected HugeClient requireGraphSpaceWrite(String graphSpace) { + return Mockito.mock(HugeClient.class); + } } private static final class Fixture { diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsControllerCanonicalTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsControllerCanonicalTest.java index d22567261..ca0f4f05e 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsControllerCanonicalTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsControllerCanonicalTest.java @@ -31,8 +31,12 @@ import org.springframework.web.util.NestedServletException; import org.apache.hugegraph.controller.graphs.GraphsController; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.controller.BaseController; import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.entity.graphs.GraphCloneEntity; import org.apache.hugegraph.exception.ExternalException; +import org.apache.hugegraph.options.HubbleOptions; import org.apache.hugegraph.service.graphs.GraphsService; import org.apache.hugegraph.testutil.Assert; @@ -54,6 +58,10 @@ public void setup() throws Exception { GraphsController controller = new GraphsController(); this.graphsService = Mockito.mock(GraphsService.class); this.setField(controller, "graphsService", this.graphsService); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.PD_ENABLED)).thenReturn(false); + this.setField(controller, "config", config); + this.setField(BaseController.class, controller, "config", config); this.client = Mockito.mock(HugeClient.class); this.withClient = request -> { @@ -206,9 +214,44 @@ public void testCanonicalGetDefaultGraphUsesServiceDefault() Mockito.verify(this.graphsService).getDefault(this.client); } + @Test + public void testCloneValidatesBodyTargetGraphSpace() throws Exception { + ScopeCapturingController controller = new ScopeCapturingController(); + controller.client = this.client; + this.setField(controller, "graphsService", this.graphsService); + GraphCloneEntity clone = GraphCloneEntity.builder() + .graphSpace("target") + .name("copy") + .build(); + + controller.clone("source", "original", clone); + + Assert.assertEquals("target", controller.checkedGraphSpace); + Mockito.verify(this.graphsService).clone( + Mockito.eq(this.client), + Mockito.argThat(params -> "target".equals( + params.get("graphspace")))); + } + private void setField(Object object, String name, Object value) throws Exception { - Field field = GraphsController.class.getDeclaredField(name); + Class type = object.getClass(); + while (type != null) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(object, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } + + private void setField(Class type, Object object, String name, + Object value) throws Exception { + Field field = type.getDeclaredField(name); field.setAccessible(true); field.set(object, value); } @@ -218,6 +261,7 @@ private static class ScopeCapturingController extends GraphsController { private HugeClient client; private String graphspace; private String graph; + private String checkedGraphSpace; @Override protected HugeClient authClient(String graphspace, String graph) { @@ -225,5 +269,11 @@ protected HugeClient authClient(String graphspace, String graph) { this.graph = graph; return this.client; } + + @Override + protected void requireGraphSpaceAccess(HugeClient client, + String graphSpace) { + this.checkedGraphSpace = graphSpace; + } } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java index 7cd14011a..e824dff54 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/GraphsServiceDefaultTest.java @@ -401,6 +401,35 @@ public void testGraphProfilesFallBackForStandaloneServer() { Mockito.verify(this.graphs, Mockito.never()).getGraph("other"); } + @Test + public void testGraphProfilesFallBackWhenProfileListIsEmpty() { + Mockito.when(this.graphs.listProfile("")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphs.listGraph()) + .thenReturn(Collections.singletonList("hugegraph")); + Mockito.when(this.graphs.getGraph("hugegraph")) + .thenReturn(Collections.singletonMap("backend", "hstore")); + Mockito.when(this.client.assignGraph("DEFAULT", "hugegraph")) + .thenReturn(this.client); + GraphManager graph = Mockito.mock(GraphManager.class); + Mockito.when(this.client.graph()).thenReturn(graph); + GraphMetricsAPI.ElementCount snapshot = new GraphMetricsAPI.ElementCount(); + snapshot.setVertices(0L); + snapshot.setEdges(0L); + Mockito.when(graph.getEVCount(Mockito.anyString())).thenReturn(snapshot); + + List> result = + this.service.sortedGraphsProfile(this.client, "DEFAULT", "", "", + false, + Collections.emptyMap()); + + Assert.assertEquals(1, result.size()); + Assert.assertEquals("hugegraph", result.get(0).get("name")); + Assert.assertEquals("hstore", result.get(0).get("backend")); + Mockito.verify(this.graphs).listGraph(); + Mockito.verify(this.graphs).getGraph("hugegraph"); + } + @Test public void testGraphProfilesDoNotMaskForbiddenResponse() { ServerException forbidden = new ServerException("forbidden"); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java index e8f6f038f..b55def2bb 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/LoaderScopeControllerTest.java @@ -23,6 +23,7 @@ import org.apache.hugegraph.controller.load.FileMappingController; import org.apache.hugegraph.controller.load.JobManagerController; import org.apache.hugegraph.controller.load.LoadTaskController; +import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.entity.load.FileMapping; import org.apache.hugegraph.entity.load.JobManager; import org.apache.hugegraph.entity.load.LoadTask; @@ -39,7 +40,7 @@ public class LoaderScopeControllerTest { @Test public void testJobCreateRejectsMissingNameAsParameterError() { JobManagerService service = Mockito.mock(JobManagerService.class); - JobManagerController controller = new JobManagerController(service); + JobManagerController controller = new TestJobManagerController(service); try { controller.create("space-a", "graph-a", JobManager.builder().build()); @@ -54,7 +55,7 @@ public void testJobCreateRejectsMissingNameAsParameterError() { public void testJobCreateNormalizesOptionalNullRemarks() { JobManagerService service = Mockito.mock(JobManagerService.class); JobManager entity = JobManager.builder().jobName("task_1").build(); - JobManagerController controller = new JobManagerController(service); + JobManagerController controller = new TestJobManagerController(service); controller.create("space-a", "graph-a", entity); @@ -85,7 +86,7 @@ public void testFileClearUsesNestedScope() { .thenReturn(Collections.singletonList(mapping)); Mockito.when(taskService.taskListByJob(7)) .thenReturn(Collections.emptyList()); - FileMappingController controller = new FileMappingController(); + FileMappingController controller = new TestFileMappingController(); ReflectionTestUtils.setField(controller, "service", service); ReflectionTestUtils.setField(controller, "jobService", jobService); ReflectionTestUtils.setField(controller, "taskService", taskService); @@ -122,4 +123,26 @@ public void testLoadTaskBatchLookupUsesNestedScope() { Mockito.verify(service).list("space-a", "graph-a", 7, Arrays.asList(13, 14)); } + + private static class TestJobManagerController + extends JobManagerController { + + TestJobManagerController(JobManagerService service) { + super(service); + } + + @Override + protected HugeClient requireGraphSpaceWrite(String graphSpace) { + return Mockito.mock(HugeClient.class); + } + } + + private static class TestFileMappingController + extends FileMappingController { + + @Override + protected HugeClient requireGraphSpaceWrite(String graphSpace) { + return Mockito.mock(HugeClient.class); + } + } } diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsControllerTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsControllerTest.java index ed89bdf1a..6f483ab48 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsControllerTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/OperationsControllerTest.java @@ -23,6 +23,7 @@ import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.exception.ForbiddenException; +import org.apache.hugegraph.service.auth.AuthModeService; import org.apache.hugegraph.service.auth.UserService; import org.apache.hugegraph.service.op.OperationsDataService; import org.apache.hugegraph.testutil.Assert; @@ -35,7 +36,9 @@ import org.springframework.web.context.request.ServletRequestAttributes; import org.apache.hugegraph.common.Constant; +import org.apache.hugegraph.config.HugeConfig; import org.apache.hugegraph.controller.op.OperationsController; +import org.apache.hugegraph.options.HubbleOptions; public class OperationsControllerTest { @@ -82,7 +85,29 @@ public void testAdminCanReadNodes() { Assert.assertEquals(0, response.get("total")); } + @Test + public void testAnonymousModeCanReadOperations() { + Fixture fixture = fixture("USER", true); + Mockito.when(fixture.dataService.overview(Mockito.any(), + Mockito.anySet(), + Mockito.eq(false))) + .thenReturn(Collections.singletonMap("status", "UP")); + + Map capabilities = + fixture.controller.capabilities(); + Map overview = fixture.controller.overview(false); + + Assert.assertEquals(3, + ((java.util.Set) capabilities.get( + "capabilities")).size()); + Assert.assertEquals("UP", overview.get("status")); + } + private static Fixture fixture(String level) { + return fixture(level, false); + } + + private static Fixture fixture(String level, boolean anonymous) { MockHttpServletRequest request = new MockHttpServletRequest(); request.getSession().setAttribute(Constant.USERNAME_KEY, "operator"); request.getSession().setAttribute(Constant.TOKEN_KEY, "token"); @@ -96,6 +121,11 @@ private static Fixture fixture(String level) { OperationsDataService dataService = Mockito.mock( OperationsDataService.class); OperationsController controller = new OperationsController(); + HugeConfig config = Mockito.mock(HugeConfig.class); + Mockito.when(config.get(HubbleOptions.AUTH_ENABLED)) + .thenReturn(!anonymous); + AuthModeService authMode = new AuthModeService(config); + ReflectionTestUtils.setField(controller, "authMode", authMode); ReflectionTestUtils.setField(controller, "userService", userService); ReflectionTestUtils.setField(controller, "dataService", dataService); return new Fixture(controller, dataService); diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java index a6b487b09..f2aca19e4 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -25,6 +25,8 @@ import org.apache.hugegraph.controller.langchain.LangChainControllerSecurityTest; import org.apache.hugegraph.controller.schema.SchemaControllerSecurityTest; import org.apache.hugegraph.controller.space.GraphSpaceControllerTest; +import org.apache.hugegraph.controller.space.SchemaTemplateControllerSecurityTest; +import org.apache.hugegraph.config.HubbleConfigEnvironmentTest; import org.apache.hugegraph.handler.ResponseAdvisorStatusTest; import org.apache.hugegraph.service.load.IngestTransactionIntegrationTest; import org.apache.hugegraph.service.auth.AuthContextServiceTest; @@ -62,6 +64,7 @@ GraphsControllerCanonicalTest.class, GremlinUtilTest.class, GremlinHistoryFailureTest.class, + HubbleConfigEnvironmentTest.class, HubbleOptionsTest.class, IngestControllerTest.class, IngestTransactionIntegrationTest.class, @@ -69,6 +72,7 @@ LegacyFacadeRemovalTest.class, MessageSourceHandlerTest.class, SchemaControllerSecurityTest.class, + SchemaTemplateControllerSecurityTest.class, GroovySchemaCompatibilityTest.class, JobManagerServiceTest.class, K8sTokenEndpointSecurityTest.class, diff --git a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java index ae0319de2..720915e25 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 @@ -18,6 +18,9 @@ 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; @@ -27,11 +30,18 @@ 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; import org.apache.hugegraph.structure.auth.User; @@ -40,6 +50,9 @@ public class UserServiceCompatibilityTest { private HugeConfig config; private HugeClient client; private AuthManager auth; + private GraphSpaceManager graphSpace; + private GraphsManager graphs; + private GraphSpaceUserService graphSpaceUsers; private UserService service; @Before @@ -47,11 +60,20 @@ public void setup() { this.config = Mockito.mock(HugeConfig.class); this.client = Mockito.mock(HugeClient.class); this.auth = Mockito.mock(AuthManager.class); + this.graphSpace = Mockito.mock(GraphSpaceManager.class); + this.graphs = Mockito.mock(GraphsManager.class); + this.graphSpaceUsers = Mockito.mock(GraphSpaceUserService.class); Mockito.when(this.client.auth()).thenReturn(this.auth); + Mockito.when(this.client.graphSpace()).thenReturn(this.graphSpace); + Mockito.when(this.client.graphs()).thenReturn(this.graphs); Mockito.when(this.auth.createUser(Mockito.any(User.class))) .thenReturn(new User()); + Mockito.when(this.auth.listSuperAdmin()) + .thenReturn(java.util.Collections.emptyList()); this.service = new UserService(); ReflectionTestUtils.setField(this.service, "config", this.config); + ReflectionTestUtils.setField(this.service, "graphSpaceUserService", + this.graphSpaceUsers); } @Test @@ -98,19 +120,23 @@ public void testStandaloneUserUpdateOmitsPdOnlyNickname() { .name("user") .nickname("display-name") .build(); + user.setSuperadmin(true); this.service.update(this.client, user); ArgumentCaptor request = ArgumentCaptor.forClass(User.class); Mockito.verify(this.auth).updateUser(request.capture()); Assert.assertNull(request.getValue().nickname()); + Mockito.verify(this.auth, Mockito.never()) + .addSuperAdmin(Mockito.anyString()); } @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.supportsPersonalProfileUpdate()) + .thenReturn(true); + Mockito.when(this.client.findCurrentUser("user")).thenReturn(user("user")); this.service.updatePersonal(this.client, "user", "display-name", "description"); @@ -121,6 +147,662 @@ public void testStandalonePersonalUpdateOmitsPdOnlyNickname() { 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 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.findCurrentUser("user")) + .thenReturn(user("user")); + Mockito.when(this.graphSpace.listGraphSpace()) + .thenReturn(java.util.Collections.singletonList("SPACE")); + Mockito.when(this.auth.checkDefaultRole("SPACE", "analyst")) + .thenReturn(true); + + UserEntity result = this.service.getpersonal(this.client, "user"); + + Assert.assertEquals("GS_READ_WRITE", + result.getGraphspacePermissions().get(0) + .get("permission_preset")); + Mockito.verify(this.graphSpace, Mockito.never()) + .checkDefaultRole(Mockito.anyString(), Mockito.anyString(), + Mockito.anyString()); + } + + @Test + public void testUserDetailFindsAdminSpacesByUsername() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + User account = user("alice"); + account.setId("user-id"); + Mockito.when(this.auth.getUser("user-id")).thenReturn(account); + Mockito.when(this.auth.listUsers()) + .thenReturn(Collections.singletonList(account)); + Mockito.when(this.graphSpace.listGraphSpace()) + .thenReturn(Collections.singletonList("SPACE")); + Mockito.when(this.auth.listSpaceAdmin("SPACE")) + .thenReturn(Collections.singletonList("alice")); + + UserEntity result = this.service.get(this.client, "user-id"); + + Assert.assertEquals(Collections.singletonList("SPACE"), + result.getAdminSpaces()); + Assert.assertEquals(Integer.valueOf(1), result.getSpacenum()); + } + + @Test + public void testCurrentUserIgnoresOnlyForbiddenGraphSpaces() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(this.client.findCurrentUser("user")) + .thenReturn(user("user")); + Mockito.when(this.graphSpace.listGraphSpace()) + .thenReturn(Arrays.asList("OWNED", "DENIED")); + Mockito.when(this.auth.checkDefaultRole("OWNED", "analyst")) + .thenReturn(true); + ServerException forbidden = new ServerException("forbidden"); + forbidden.status(403); + Mockito.when(this.graphs.listGraph()).thenThrow(forbidden); + + UserEntity result = this.service.getpersonal(this.client, "user"); + + Assert.assertEquals(Collections.singletonList("OWNED"), + result.getResSpaces()); + Assert.assertEquals(1, result.getGraphspacePermissions().size()); + } + + @Test + public void testLegacySpaceMemberKeepsGraphSpaceAccess() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + Mockito.when(this.client.findCurrentUser("user")) + .thenReturn(user("user")); + Mockito.when(this.graphSpace.listGraphSpace()) + .thenReturn(Collections.singletonList("SPACE")); + Mockito.when(this.auth.isSpaceMember("SPACE")).thenReturn(true); + + UserEntity result = this.service.getpersonal(this.client, "user"); + + Assert.assertEquals(Collections.singletonList("SPACE"), + result.getResSpaces()); + Assert.assertEquals("LEGACY_CUSTOM", result.getPermissionPreset()); + Mockito.verify(this.auth, Mockito.never()) + .checkDefaultRole(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void testLegacyUserDetailKeepsMembershipWithoutPreset() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + User account = user("alice"); + account.setId("user-id"); + Mockito.when(this.auth.getUser("user-id")).thenReturn(account); + Mockito.when(this.auth.listUsers()) + .thenReturn(Collections.singletonList(account)); + Mockito.when(this.graphSpace.listGraphSpace()) + .thenReturn(Collections.singletonList("SPACE")); + Mockito.when(this.auth.listSpaceAdmin("SPACE")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpaceUsers.hasGraphSpaceAccess( + this.client, "SPACE", "alice")) + .thenReturn(true); + + UserEntity result = this.service.get(this.client, "user-id"); + + Assert.assertEquals(Collections.singletonList("SPACE"), + result.getResSpaces()); + Assert.assertEquals(Collections.emptyList(), + result.getGraphspacePermissions()); + Assert.assertEquals("LEGACY_CUSTOM", result.getPermissionPreset()); + Assert.assertEquals(Collections.emptyList(), result.getAdminSpaces()); + } + + @Test + public void testLegacyProfileUpdatePreservesPermissionAssignments() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + UserEntity user = UserEntity.builder() + .id("user-id") + .name("user") + .nickname("display-name") + .build(); + + this.service.update(this.client, user); + + 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).updateUser(Mockito.any(User.class)); + } + + @Test + public void testModernProfileUpdatePreservesPermissionAssignments() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + UserEntity user = UserEntity.builder() + .id("user-id") + .name("user") + .nickname("display-name") + .build(); + + this.service.update(this.client, user); + + Mockito.verify(this.auth).updateUser(Mockito.any(User.class)); + 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 testModernUserUpdateUsesPresetAsPermissionSource() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(this.auth.getUser("user")).thenReturn(user("user")); + Map 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.setPermissionPreset("GS_ADMIN"); + account.setGraphspacePermissions( + Collections.singletonList(permission)); + + this.service.update(this.client, account); + + 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 testModernCombinedPasswordAndPermissionUpdateIsRejected() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + UserEntity account = UserEntity.builder() + .id("user") + .name("user") + .password("new-password") + .build(); + account.setPermissionPreset("GS_READ_ONLY"); + account.setGraphspacePermissions(Collections.singletonList( + permission("team", "GS_READ_ONLY"))); + + try { + this.service.update(this.client, account); + Assert.fail("Expected combined update to be rejected"); + } catch (ParameterizedException ignored) { + // Expected + } + + Mockito.verify(this.auth, Mockito.never()) + .updateUser(Mockito.any(User.class)); + Mockito.verify(this.graphSpaceUsers, Mockito.never()) + .applyPermissionPresets(Mockito.any(), Mockito.anyString(), + Mockito.any(), Mockito.any()); + } + + @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 updates = ArgumentCaptor.forClass(User.class); + Mockito.verify(this.auth, Mockito.times(2)) + .updateUser(updates.capture()); + Assert.assertSame(previous, updates.getAllValues().get(1)); + } + + @Test + public void testLegacyUserUpdateRejectsPermissionChangesBeforeWrites() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + 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()); + UserEntity account = UserEntity.builder() + .id("user") + .name("user") + .adminSpaces( + Collections.singletonList("NEW")) + .build(); + + ParameterizedException error = null; + try { + this.service.update(this.client, account); + } catch (ParameterizedException e) { + error = e; + } + + Assert.assertNotNull(error); + Assert.assertEquals("auth.permission-preset.unsupported", + error.getMessage()); + 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.auth, Mockito.never()) + .updateUser(Mockito.any(User.class)); + } + + @Test + public void testLegacyUserUpdateRejectsPresetBeforeProfileWrite() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + UserEntity account = UserEntity.builder() + .id("user") + .name("user") + .permissionPreset("GS_READ_ONLY") + .graphspacePermissions( + Collections.singletonList( + permission( + "SPACE", + "GS_READ_ONLY"))) + .build(); + + ParameterizedException error = null; + try { + this.service.update(this.client, account); + } catch (ParameterizedException e) { + error = e; + } + + Assert.assertNotNull(error); + Assert.assertEquals("auth.permission-preset.unsupported", + error.getMessage()); + Mockito.verify(this.auth, Mockito.never()) + .updateUser(Mockito.any(User.class)); + } + + @Test + public void testPdUserListReportsPresetAndCustomRoleState() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(true); + Mockito.when(this.auth.listUsers()) + .thenReturn(Collections.singletonList(user("alice"))); + Mockito.when(this.graphSpace.listGraphSpace()) + .thenReturn(Arrays.asList("ADMIN", "WRITE", "READ", "CUSTOM")); + Mockito.when(this.auth.listSpaceAdmin("ADMIN")) + .thenReturn(Collections.singletonList("alice")); + Mockito.when(this.auth.listSpaceAdmin("WRITE")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceAdmin("READ")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.auth.listSpaceAdmin("CUSTOM")) + .thenReturn(Collections.emptyList()); + Mockito.when(this.graphSpace.checkDefaultRole( + "WRITE", "alice", "analyst")).thenReturn(true); + Mockito.when(this.graphSpace.checkDefaultRole( + "READ", "alice", "observer")).thenReturn(true); + Mockito.when(this.graphSpaceUsers.hasCustomRoles( + this.client, "CUSTOM", "alice")).thenReturn(true); + + UserEntity account = this.service.listUsers(this.client).get(0); + + Assert.assertEquals(Integer.valueOf(1), account.getSpacenum()); + Assert.assertEquals(Collections.singletonList("ADMIN"), + account.getAdminSpaces()); + Assert.assertEquals(3, account.getGraphspacePermissions().size()); + Assert.assertEquals("GS_ADMIN", + account.getGraphspacePermissions().get(0) + .get("permission_preset")); + Assert.assertEquals("GS_READ_WRITE", + account.getGraphspacePermissions().get(1) + .get("permission_preset")); + Assert.assertEquals("GS_READ_ONLY", + account.getGraphspacePermissions().get(2) + .get("permission_preset")); + Assert.assertEquals("LEGACY_CUSTOM", account.getPermissionPreset()); + } + + @Test + public void testLegacyUserCreationRejectsPermissionPreset() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + UserEntity user = userEntity("display-name"); + user.setPermissionPreset("GS_READ_ONLY"); + user.setGraphspacePermissions(java.util.Collections.singletonList( + java.util.Collections.singletonMap( + "permission_preset", "GS_READ_ONLY"))); + + ParameterizedException error = null; + try { + this.service.add(this.client, user); + } catch (ParameterizedException e) { + error = e; + } + + Assert.assertNotNull(error); + Assert.assertEquals("auth.permission-preset.unsupported", + error.getMessage()); + Mockito.verify(this.auth, Mockito.never()) + .createUser(Mockito.any(User.class)); + 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()); + } + + @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 testLegacyAdminGrantIsRejectedBeforeAccountCreation() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + UserEntity user = userEntity("display-name"); + user.setAdminSpaces(Arrays.asList("FIRST", "SECOND")); + + ParameterizedException error = null; + try { + this.service.add(this.client, user); + } catch (ParameterizedException e) { + error = e; + } + + Assert.assertNotNull(error); + Assert.assertEquals("auth.permission-preset.unsupported", + error.getMessage()); + Mockito.verify(this.auth, Mockito.never()) + .createUser(Mockito.any(User.class)); + Mockito.verify(this.auth, Mockito.never()) + .addSpaceAdmin(Mockito.anyString(), Mockito.anyString()); + } + + @Test + public void testLegacyAdminSpaceEndpointRejectsBeforeWrites() { + Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); + Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); + + ParameterizedException error = null; + try { + this.service.updateAdminSpace( + this.client, "user", Collections.singletonList("SPACE")); + } catch (ParameterizedException e) { + error = e; + } + + Assert.assertNotNull(error); + Assert.assertEquals("auth.permission-preset.unsupported", + error.getMessage()); + Mockito.verify(this.auth, Mockito.never()) + .addSpaceAdmin(Mockito.anyString(), Mockito.anyString()); + } + + @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") @@ -135,4 +817,12 @@ private static User user(String name) { user.name(name); return user; } + + private static Map permission(String graphSpace, + String preset) { + Map permission = new HashMap<>(); + permission.put("graphspace", graphSpace); + permission.put("permission_preset", preset); + return permission; + } } diff --git a/hugegraph-hubble/hubble-dist/assembly/static/conf/hugegraph-hubble.properties b/hugegraph-hubble/hubble-dist/assembly/static/conf/hugegraph-hubble.properties index 8dfce484c..442560763 100644 --- a/hugegraph-hubble/hubble-dist/assembly/static/conf/hugegraph-hubble.properties +++ b/hugegraph-hubble/hubble-dist/assembly/static/conf/hugegraph-hubble.properties @@ -28,6 +28,9 @@ idc=bddwd client.url_cache_max_entries=1024 # ===== Deployment Mode ===== +# Require an authenticated Hubble session. HUBBLE_AUTH_ENABLED can explicitly +# override this value for container and orchestrated deployments. +auth.enabled=true # Set to false for standalone RocksDB mode (no PD dependency) pd.enabled=false # Direct server URL, only used when pd.enabled=false diff --git a/hugegraph-hubble/hubble-fe/src/App.js b/hugegraph-hubble/hubble-fe/src/App.js index cf669d538..ec6ee6aa8 100644 --- a/hugegraph-hubble/hubble-fe/src/App.js +++ b/hugegraph-hubble/hubble-fe/src/App.js @@ -23,9 +23,49 @@ import './App.css'; import './styles/workbench.scss'; import Layout from './layout.ant'; import {AuthContextProvider} from './auth/AuthContext'; +import * as api from './api'; +import {setConfig} from './utils/config'; +import {useEffect, useState} from 'react'; function App() { + const [configReady, setConfigReady] = useState(false); + const [configError, setConfigError] = useState(false); + useEffect(() => { + let active = true; + api.config.getConfig() + .then(response => { + if (response?.status !== 200 || !response.data) { + throw new Error('invalid_hubble_config'); + } + if (active) { + setConfig(response.data); + setConfigReady(true); + } + }) + .catch(() => { + if (active) { + setConfigError(true); + } + }); + return () => { + active = false; + }; + }, []); + + if (configError) { + return ( +
+ Unable to load Hubble configuration. + +
+ ); + } + if (!configReady) { + return null; + } return (
diff --git a/hugegraph-hubble/hubble-fe/src/App.test.js b/hugegraph-hubble/hubble-fe/src/App.test.js index 11805599a..a3ed5534d 100644 --- a/hugegraph-hubble/hubble-fe/src/App.test.js +++ b/hugegraph-hubble/hubble-fe/src/App.test.js @@ -19,15 +19,29 @@ import {render, screen} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import App from './App'; +import * as api from './api'; + +jest.mock('./api', () => ({ + config: { + getConfig: jest.fn(), + }, +})); jest.mock('./routes', () => ({element}) => (
{element}
)); jest.mock('./layout.ant', () => () =>
Hubble layout
); +jest.mock('./auth/AuthContext', () => ({ + AuthContextProvider: ({children}) => children, +})); -test('wires the Hubble layout into the application router', () => { +test('wires the Hubble layout into the application router', async () => { sessionStorage.clear(); + api.config.getConfig.mockResolvedValue({ + status: 200, + data: {pd_enabled: false, auth_enabled: false}, + }); render( { ); - expect(screen.getByTestId('app-route')).toBeInTheDocument(); + expect(await screen.findByTestId('app-route')).toBeInTheDocument(); expect(screen.getByText('Hubble layout')).toBeInTheDocument(); + expect(JSON.parse(sessionStorage.getItem('hubble_config_'))).toEqual({pd_enabled: false, auth_enabled: false}); +}); + +test('shows a retry surface when configuration bootstrap fails', async () => { + api.config.getConfig.mockRejectedValue(new Error('offline')); + + render( + + + + ); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Unable to load Hubble configuration.' + ); + expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument(); }); diff --git a/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js b/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js index 8c435cbc6..239ad6578 100644 --- a/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js +++ b/hugegraph-hubble/hubble-fe/src/api/auth-contract.test.js @@ -105,6 +105,14 @@ describe('auth API contract', () => { it.each([ ['getSpaceMembers', ['A/B', {page_no: 1}], 'get', '/graphspaces/A%2FB/auth/users'], + ['getSpaceAdmins', ['A/B', {page_no: 1}], 'get', + '/graphspaces/A%2FB/auth/users/spaceadmin'], + ['setSpaceAdmin', ['A/B', 'u/1'], 'post-empty', + '/graphspaces/A%2FB/auth/users/spaceadmin/u%2F1'], + ['removeSpaceAdmin', ['A/B', 'u/1'], 'delete', + '/graphspaces/A%2FB/auth/users/spaceadmin/u%2F1'], + ['setSpacePreset', ['A/B', 'u/1', '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'], ['updateSpaceMember', ['A/B', 'u/1', {roles: []}], 'put', @@ -151,6 +159,20 @@ describe('auth API contract', () => { route, expectedParams, config ); } + else if (verb === 'post-empty') { + expect(mockRequest.post).toHaveBeenCalledWith( + route, undefined, config + ); + } + else if (verb === 'put-preset') { + expect(mockRequest.put).toHaveBeenCalledWith( + route, { + user_id: args[1], + username: args[2], + permission_preset: args[3], + }, config + ); + } else { expect(mockRequest[verb]).toHaveBeenCalledWith( route, args.at(-1), config diff --git a/hugegraph-hubble/hubble-fe/src/api/auth.js b/hugegraph-hubble/hubble-fe/src/api/auth.js index 18a5dee05..6a7c97506 100644 --- a/hugegraph-hubble/hubble-fe/src/api/auth.js +++ b/hugegraph-hubble/hubble-fe/src/api/auth.js @@ -97,6 +97,26 @@ const getSpaceMembers = (graphspace, params, config = {}) => { return request.get(scopedAuthPath(graphspace, 'users'), {...config, params}); }; +const getSpaceAdmins = (graphspace, params, config = {}) => { + return request.get(scopedAuthPath(graphspace, 'users/spaceadmin'), {...config, params}); +}; + +const setSpaceAdmin = (graphspace, id, config) => { + return request.post(scopedAuthPath(graphspace, 'users/spaceadmin', id), undefined, config); +}; + +const removeSpaceAdmin = (graphspace, id, config) => { + return request.delete(scopedAuthPath(graphspace, 'users/spaceadmin', id), undefined, config); +}; + +const setSpacePreset = (graphspace, id, username, preset, config) => { + return request.put(`${scopedAuthPath(graphspace, 'users', id)}/preset`, { + user_id: id === username ? undefined : id, + username, + permission_preset: preset, + }, config); +}; + const addSpaceMember = (graphspace, data, config) => { return request.post(scopedAuthPath(graphspace, 'users'), data, config); }; @@ -161,6 +181,10 @@ const deleteSpaceAccess = (graphspace, roleId, targetId, config) => { export { getSpaceMembers, + getSpaceAdmins, + setSpaceAdmin, + removeSpaceAdmin, + setSpacePreset, addSpaceMember, updateSpaceMember, deleteSpaceMember, 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..c95d11e38 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,8 @@ const loadResponseHandlers = modulePath => { const messageError = jest.fn(); const modalWarning = jest.fn(); const clearLogin = jest.fn(); + const isLogoutTransition = jest.fn(() => false); + const isAuthEnabled = jest.fn(() => true); const instance = { interceptors: { request: { @@ -56,6 +58,10 @@ const loadResponseHandlers = modulePath => { })); jest.doMock('../utils/user', () => ({ clearLogin, + isLogoutTransition, + })); + jest.doMock('../utils/config', () => ({ + isAuthEnabled, })); const request = require(modulePath).default; @@ -65,6 +71,8 @@ const loadResponseHandlers = modulePath => { messageError, modalWarning, clearLogin, + isLogoutTransition, + isAuthEnabled, instance, request, }; @@ -78,6 +86,7 @@ describe.each(['./request'])('%s error semantics', modulePath => { search: '?x=1', hash: '#result', href: '', + replace: jest.fn(), }; }); @@ -86,6 +95,7 @@ describe.each(['./request'])('%s error semantics', modulePath => { jest.dontMock('antd'); jest.dontMock('../i18n'); jest.dontMock('../utils/user'); + jest.dontMock('../utils/config'); localStorage.clear(); sessionStorage.clear(); }); @@ -235,6 +245,28 @@ describe.each(['./request'])('%s error semantics', modulePath => { expect(instance.delete).not.toHaveBeenCalled(); }); + it('keeps anonymous HTTP 401 local and shows the resource error', async () => { + const {reject, clearLogin, isAuthEnabled, messageError} + = loadResponseHandlers(modulePath); + isAuthEnabled.mockReturnValue(false); + const error = { + config: {url: '/graphspaces/protected/graphs/graph/schema'}, + response: { + status: 401, + data: { + status: 401, + message: 'GraphSpace is unavailable', + }, + }, + }; + + await expect(reject(error)).rejects.toBe(error); + expect(clearLogin).not.toHaveBeenCalled(); + expect(sessionStorage.getItem('redirect')).toBeNull(); + expect(window.location.href).toBe(''); + expect(messageError).toHaveBeenCalledWith('request.error'); + }); + it('rejects business 401 and redirects to login', async () => { const {resolve, clearLogin, instance} = loadResponseHandlers(modulePath); const response = { @@ -258,6 +290,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..32e0aac98 100644 --- a/hugegraph-hubble/hubble-fe/src/api/request.js +++ b/hugegraph-hubble/hubble-fe/src/api/request.js @@ -25,6 +25,7 @@ import * as user from '../utils/user'; import {withLanguageHeader} from './languageHeader'; import {showThrottleWarning} from './throttleWarning'; import {AUTH_REVALIDATE_EVENT} from '../utils/authEvents'; +import {isAuthEnabled} from '../utils/config'; import {sanitizePublicError} from '../utils/publicError'; const isJsonResponse = headers => { @@ -40,7 +41,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; @@ -146,9 +153,12 @@ instance.interceptors.response.use( if (isLoginRequest(response.config)) { showLoginAuthError(response); } - else { + else if (isAuthEnabled()) { redirectToLogin(); } + else { + showRequestError(response.data); + } return Promise.reject(response); } else if (response.data?.status === 429) { @@ -178,9 +188,12 @@ instance.interceptors.response.use( if (isLoginRequest(error.config)) { showLoginAuthError(error.response); } - else { + else if (isAuthEnabled()) { redirectToLogin(); } + else { + showRequestError(error.response?.data); + } return Promise.reject(error); } if (error.response?.status === 429 @@ -213,7 +226,7 @@ const request = {}; const responseData = response => { const data = response?.data; - if (data?.status === 401) { + if (data?.status === 401 && isAuthEnabled()) { redirectToLogin(); } return data; diff --git a/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js b/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js index 1d9bc0a8d..6b553e7da 100644 --- a/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js +++ b/hugegraph-hubble/hubble-fe/src/auth/AuthContext.js @@ -29,6 +29,7 @@ import {useLocation} from 'react-router-dom'; import * as api from '../api/index'; import {AUTH_REVALIDATE_EVENT} from '../utils/authEvents'; import {getUser, USER_CHANGE_EVENT} from '../utils/user'; +import {isAuthEnabled} from '../utils/config'; const REFRESH_INTERVAL_MS = 60_000; const MIN_REFRESH_INTERVAL_MS = 5_000; @@ -41,6 +42,7 @@ const AuthContext = createContext({ }); const isSignedIn = () => Boolean(getUser()?.id); +const shouldLoadContext = () => isSignedIn() || !isAuthEnabled(); const unwrapContext = response => { if (response?.status !== 200 || !response.data @@ -60,11 +62,11 @@ const AuthContextProvider = ({children}) => { const lastSuccessRef = useRef(0); const [identityEpoch, setIdentityEpoch] = useState(0); const [state, setState] = useState(() => ( - isSignedIn() ? {...emptyState, loading: true} : emptyState + shouldLoadContext() ? {...emptyState, loading: true} : emptyState )); const load = useCallback(({force = false} = {}) => { - if (!isSignedIn()) { + if (!shouldLoadContext()) { setState(emptyState); return Promise.resolve(null); } @@ -87,7 +89,7 @@ const AuthContextProvider = ({children}) => { .then(context => { if (epoch === epochRef.current && requestId === latestRequestRef.current - && isSignedIn()) { + && shouldLoadContext()) { lastSuccessRef.current = Date.now(); setState({loading: false, context, error: null}); } @@ -116,7 +118,7 @@ const AuthContextProvider = ({children}) => { inFlightRef.current = null; lastSuccessRef.current = 0; setIdentityEpoch(epochRef.current); - if (isSignedIn()) { + if (shouldLoadContext()) { load({force: true}).catch(() => undefined); } else { diff --git a/hugegraph-hubble/hubble-fe/src/auth/graphspaceAccess.js b/hugegraph-hubble/hubble-fe/src/auth/graphspaceAccess.js new file mode 100644 index 000000000..deadbf473 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/auth/graphspaceAccess.js @@ -0,0 +1,47 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {useAuthContext} from './AuthContext'; +import {isPdEnabled} from '../utils/config'; + +const includes = (values, target) => Array.isArray(values) + && values.includes(target); + +const resolveGraphspaceAccess = (context, graphspace, pdEnabled) => { + if (!context) { + return {canManage: false, canWrite: false}; + } + + const scopes = context.scopes ?? {}; + const anonymousStandalone = context.mode === 'NON_AUTH' && !pdEnabled; + const canManage = anonymousStandalone + || context.role === 'SUPERADMIN' + || scopes.all_graphspaces === true + || includes(scopes.admin_graphspaces, graphspace); + const canWrite = canManage + || context.mode === 'NON_PD' + || includes(scopes.write_graphspaces, graphspace); + return {canManage, canWrite}; +}; + +const useGraphspaceAccess = graphspace => { + const {context} = useAuthContext(); + return resolveGraphspaceAccess(context, graphspace, isPdEnabled()); +}; + +export {resolveGraphspaceAccess, useGraphspaceAccess}; diff --git a/hugegraph-hubble/hubble-fe/src/auth/graphspaceAccess.test.js b/hugegraph-hubble/hubble-fe/src/auth/graphspaceAccess.test.js new file mode 100644 index 000000000..bf2cff573 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/auth/graphspaceAccess.test.js @@ -0,0 +1,43 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {resolveGraphspaceAccess} from './graphspaceAccess'; + +test.each([ + [null, true, false, false], + [{mode: 'PD', role: 'USER', scopes: {}}, true, false, false], + [{ + mode: 'PD', + role: 'USER', + scopes: {write_graphspaces: ['space']}, + }, true, false, true], + [{ + mode: 'PD', + role: 'SPACEADMIN', + scopes: {admin_graphspaces: ['space']}, + }, true, true, true], + [{mode: 'NON_PD', role: 'USER', scopes: {}}, false, false, true], + [{mode: 'NON_PD', role: 'SUPERADMIN', scopes: {}}, false, true, true], + [{mode: 'NON_AUTH', role: 'ANONYMOUS', scopes: {}}, false, true, true], +])( + 'resolves graphspace access for %#', + (context, pdEnabled, canManage, canWrite) => { + expect(resolveGraphspaceAccess(context, 'space', pdEnabled)) + .toEqual({canManage, canWrite}); + } +); diff --git a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js index e9f09b336..bdb628edc 100644 --- a/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js +++ b/hugegraph-hubble/hubble-fe/src/components/GraphContextSwitcher/index.js @@ -64,11 +64,14 @@ const getRecords = response => { return response.data?.records ?? []; }; -const errorKind = error => { - const status = error?.status ?? error?.response?.data?.status - ?? error?.response?.status; - return status === 403 ? 'forbidden' : true; -}; +const errorStatus = error => error?.status ?? error?.response?.data?.status + ?? error?.response?.status; + +const errorKind = error => ( + errorStatus(error) === 403 ? 'forbidden' : true +); + +const unavailableGraphSpace = error => [403, 404].includes(errorStatus(error)); const GraphContextSwitcher = () => { const {t} = useTranslation(); @@ -156,7 +159,17 @@ const GraphContextSwitcher = () => { }) .catch(error => { if (!cancelled) { - setErrors(value => ({...value, graphs: errorKind(error)})); + const kind = errorKind(error); + if (pdEnabled && unavailableGraphSpace(error)) { + 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 +177,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 +186,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..4002b2ed2 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,46 @@ 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('clears a masked missing PD GraphSpace after access is revoked', 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: 404, 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_load_failed')) + .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/Sidebar/index.ant.js b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js index 6ed39db42..a75b2e76a 100644 --- a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js +++ b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.js @@ -30,7 +30,7 @@ import { ClusterOutlined, } from '@ant-design/icons'; import {Link, useLocation} from 'react-router-dom'; -import {isPdEnabled} from '../../utils/config'; +import {isAuthEnabled, isPdEnabled} from '../../utils/config'; import {getGraphspacePath} from '../../utils/productMode'; import {getPreparationSchemaPath} from '../../utils/dataPreparationNavigation'; import {getSidebarMenuKey} from '../../utils/sidebarNavigation'; @@ -46,11 +46,11 @@ const items = (t, pathname, capabilities = []) => { const ACCOUNT = {label: {t('home.account')}, key: 'account'}; // TODO temporary hided the resource and role modules - let systemList = [MY]; + let systemList = isAuthEnabled() ? [MY] : []; if (capabilities.includes('accounts_manage') || capabilities.includes('graphspace_members_manage')) { // systemList = [MY, RESOURCE, ROLE]; - systemList = [MY, ACCOUNT]; + systemList = isAuthEnabled() ? [MY, ACCOUNT] : []; } const operationsList = [ ...(pdMode && capabilities.includes('operations_health_read') ? [{ @@ -63,6 +63,7 @@ const items = (t, pathname, capabilities = []) => { key: 'nodes', }] : []), ]; + const supportList = [...operationsList, ...systemList]; const menu = [ { @@ -125,12 +126,12 @@ const items = (t, pathname, capabilities = []) => { }, ], }, - { + ...(supportList.length > 0 ? [{ label: t('operations.section'), key: 'support', icon: , - children: [...operationsList, ...systemList], - }, + children: supportList, + }] : []), ]; return menu; diff --git a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js index 541015d68..2fe9f7aa8 100644 --- a/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js +++ b/hugegraph-hubble/hubble-fe/src/components/Sidebar/index.ant.test.js @@ -73,6 +73,52 @@ test('hides PD-only operations links in standalone mode without topology access' expect(screen.queryByRole('link', {name: '节点详情'})).not.toBeInTheDocument(); }); +test('hides account and profile links in anonymous mode', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({ + pd_enabled: false, + auth_enabled: false, + })); + + render( + + + + ); + + expect(await screen.findByRole('navigation', {name: '主导航'})) + .toBeInTheDocument(); + expect(screen.queryByRole('link', {name: '个人中心'})).not.toBeInTheDocument(); + expect(screen.queryByRole('link', {name: '账号管理'})).not.toBeInTheDocument(); +}); + +test('hides an empty operations section in anonymous standalone mode', async () => { + sessionStorage.setItem('hubble_config_', JSON.stringify({ + pd_enabled: false, + auth_enabled: false, + })); + useOperationsCapabilities.mockReturnValue({ + loading: false, + capabilities: [], + error: null, + }); + + render( + + + + ); + + expect(await screen.findByRole('navigation', {name: '主导航'})) + .toBeInTheDocument(); + expect(screen.queryByText('系统与运维')).not.toBeInTheDocument(); +}); + test('keeps monitoring and account links in one operations section', async () => { sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); render( diff --git a/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js b/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js index 32cc7754e..638f02974 100644 --- a/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js +++ b/hugegraph-hubble/hubble-fe/src/components/Topbar/index.ant.js @@ -32,9 +32,19 @@ import { clearPersistedAlgorithmFormsForUser, } from '../../modules/algorithm/algorithmsForm/algorithmFormPersistence'; 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(); @@ -50,13 +60,13 @@ const Topbar = () => { useEffect(() => { let cancelled = false; - if (!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(); } }) @@ -67,27 +77,37 @@ const Topbar = () => { return () => { cancelled = true; }; - }, [redirectToLogin, userInfo]); + }, [redirectToLogin, userId]); - if (!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 = { @@ -138,24 +158,26 @@ const Topbar = () => { title={t('workbench.shortcuts.open_button')} onClick={showShortcutHelp} /> - - - + } + aria-label={userLabel} + title={userLabel} + > + {avatarLabel} + + + + )}
); 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( @@ -289,5 +291,48 @@ describe('Topbar request errors', () => { await waitFor(() => expect(api.auth.logout).toHaveBeenCalledTimes(1)); expect(window.location.replace).toHaveBeenCalledWith('/login'); + view.rerender( + + + + ); + 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( + + + + ); + + fireEvent.click(screen.getByRole('button', {name: 'Topbar.exit.name'})); + await waitFor(() => expect(api.auth.logout).toHaveBeenCalledTimes(1)); + view.rerender( + + + + ); + + 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/i18n/resources/en-US/components/common.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/components/common.json index fa17a2657..8a1b8dd91 100644 --- a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/components/common.json +++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/components/common.json @@ -26,7 +26,7 @@ "property_name_rule": "Use Chinese characters, letters, numbers, or underscores only", "normal_name_rule": "Use Chinese characters, letters, numbers, or underscores only, up to 48 characters", "jdbc_rule": "Enter a valid JDBC URL, for example: jdbc:mysql://127.0.0.1:3306/db_name", - "account_name_rule": "Account name must be within 16 characters and cannot start or end with an underscore", + "account_name_rule": "Use 1–16 characters without spaces; letters, numbers, Chinese/CJK forms, and underscores are supported; underscores cannot be first or last", "favorite_name_rule": "Use Chinese characters, letters, numbers, or underscores only, up to 48 characters", "invalid_data_format": "Invalid data format" } diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json index 3c9f0fe08..7547d188f 100644 --- a/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json +++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json @@ -354,6 +354,7 @@ }, "empty": { "description": "This GraphSpace is ready but has no graphs yet. Create the first graph to model, import, and query data", + "read_only_description": "This GraphSpace has no graphs available to this read-only account", "create": "Create first graph", "demo_prerequisite": "After creating a graph, select it and use Data Import to build a quick Demo dataset.", "view_demo": "View Demo data entry", @@ -377,7 +378,8 @@ "name": "Account Name", "remark": "Remark", "level": "Account Level", - "resource": "Resource Permission", + "resource": "GraphSpaces", + "resource_all": "All", "create_time": "Created At", "is_superadmin": "Super Admin", "permission": "Admin Permission" @@ -387,6 +389,26 @@ "SPACEADMIN": "Space Administrator", "USER": "Regular User" }, + "permission_preset": { + "SUPER_ADMIN": "Super Administrator", + "GS_READ_ONLY": "GraphSpace Read-only", + "GS_READ_WRITE": "GraphSpace Read-write", + "GS_ADMIN": "GraphSpace Administrator", + "preserve_mixed": "Keep existing mixed access", + "mixed": "Mixed GraphSpace access", + "unassigned": "No GraphSpace preset assigned", + "legacy_custom": "Legacy/custom access" + }, + "feedback": { + "save_failed": "Account was not saved", + "save_retry": "Check the account fields and server connection, then retry.", + "delete_retry": "The account could not be deleted. Refresh the list and retry.", + "password_permission_separate": "Save password and permission changes separately.", + "presets_unavailable": "GraphSpace presets are unavailable", + "presets_unavailable_help": "This deployment does not provide the required permission API. The account will be created without elevated permissions", + "preset_edit_unavailable": "Permission editing is unavailable", + "preset_edit_unavailable_help": "Profile and password changes remain available; existing permissions are preserved until the connected HugeGraph REST API is upgraded to 0.72 or later" + }, "form": { "title_detail": "View Account", "title_edit": "Edit Account", @@ -396,7 +418,7 @@ "id_help": "The unique login identifier. It cannot be changed after creation", "id_placeholder": "Login username", "name": "Display Name", - "name_help": "The name shown to administrators. It can be changed later", + "name_help": "Use 1–16 characters without spaces; Chinese/CJK forms, letters, numbers, and underscores are supported", "name_placeholder": "Can be changed after creation", "is_superadmin": "Super Admin", "level": "Account Level", @@ -408,7 +430,11 @@ "default_password_help": "The initial password for a new account. The user should change it after signing in", "default_password_placeholder": "Enter a 5–16 character initial password", "permission": "Admin Permission", - "permission_help": "Select the GraphSpaces this account may administer. HugeGraph roles control ordinary access" + "permission_help": "Select the GraphSpaces this account may administer. HugeGraph roles control ordinary access", + "permission_preset": "Access preset", + "permission_preset_help": "Choose one clear access preset; low-level roles and targets stay internal", + "graphspaces": "GraphSpaces", + "graphspaces_help": "Select the GraphSpaces covered by this preset" }, "space_access": { "global_tab": "Global Accounts", @@ -428,7 +454,8 @@ "id": "Account ID", "name": "Account Name", "roles": "Roles", - "remove_confirm": "Remove this member from the GraphSpace?" + "remove_confirm": "Remove this member from the GraphSpace?", + "preset_unavailable": "The selected permission preset is not available on this GraphSpace." }, "role": { "add": "Create Role", @@ -486,7 +513,7 @@ "new_password_placeholder": "Enter new password", "confirm_password_placeholder": "Enter new password again", "password_mismatch": "Passwords do not match", - "account_name_rule": "Account name must be 1-16 characters and cannot start or end with an underscore" + "account_name_rule": "Use 1–16 characters without spaces; letters, numbers, Chinese/CJK forms, and underscores are supported; underscores cannot be first or last" } }, "role": { @@ -1078,6 +1105,7 @@ "empty_title": "Build your first graph schema", "empty_description": "Define reusable properties first, then create vertex and edge types", "create_from_template": "Create Schema from a Template", + "read_only_empty": "This graph has no Schema yet. A read-write or administrator account is required to create one.", "template_description": "Choose a built-in example or a saved template from this GraphSpace and apply it to the current graph", "manual_title": "Or create the Schema manually", "builtin_templates": "Built-in Examples", @@ -1128,7 +1156,7 @@ "read_only": { "page_title": "{{name}} - Schema Template Library", "title": "Read-only template library", - "description": "You can browse built-in and saved templates here. Creating, saving, editing, and deleting templates requires PD mode", + "description": "You can browse built-in and saved templates here. Creating, saving, editing, and deleting templates are unavailable for this connection or account", "apply_to_graph": "Apply templates to {{graph}}", "choose_graph": "Choose a graph to apply a template", "builtin": "Built in" @@ -1216,6 +1244,8 @@ "node_id_copied": "Node ID copied", "node_id_copy_failed": "Could not copy the node ID", "leader_role": "Leader role", + "leader": "Leader", + "follower": "Follower", "leader_shard": "{{count}} leader shard", "leader_shards": "{{count}} leader shards", "node_profile": "Node profile", @@ -1305,8 +1335,8 @@ "metric_scope_backend": "Backend metrics are provided by Server and Store nodes, not {{nodeType}} nodes.", "reason_upstream_timeout": "Upstream timed out", "reason_upstream_unavailable": "Upstream unavailable", - "reason_metrics_target_untrusted": "The metrics target failed the trust check. Review the PD/Prometheus target configuration.", - "reason_metrics_target_missing": "No Store metrics target was discovered. Review the target configuration.", + "reason_metrics_target_untrusted": "The Store metrics origin is not trusted. Add its exact scheme, host, and port to operations.store.allowed_targets, restart Hubble, then refresh.", + "reason_metrics_target_missing": "PD did not report a Store metrics target. Check PD target discovery and the Store REST metrics port, then refresh.", "reason_upstream_deadline": "Metrics collection timed out", "reason_metrics_not_collected": "This metric is not collected from the current node", "reason_topology_fields_unavailable": "The topology did not provide this field", @@ -1314,6 +1344,7 @@ "reason_refresh_failed": "Refresh failed", "reason_malformed_response": "Malformed response", "reason_unsupported_version": "Unsupported service version", + "reason_unsupported_version_help": "Upgrade HugeGraph to a version that provides this metric.", "reason_deployment_mode_unsupported": "Unsupported by the current deployment mode", "metric_labels": { "basic": "Memory & process", @@ -1375,6 +1406,7 @@ "datasource_manage": "Data Source Management", "operation_manage": "Operations", "cluster_overview": "Cluster Overview", + "cluster_overview_requires_pd": "Cluster Overview requires PD and is unavailable in standalone mode. Use Nodes to inspect server status.", "nodes": "Nodes", "advanced_monitoring": "External Advanced Dashboard", "operations_unavailable": "Native operations capabilities are unavailable for this account.", diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/components/common.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/components/common.json index 877d473b4..d796010c1 100644 --- a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/components/common.json +++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/components/common.json @@ -26,7 +26,7 @@ "property_name_rule": "只能包含中文、字母、数字、_", "normal_name_rule": "只能包含中文、字母、数字、_,最多 48 个字符", "jdbc_rule": "请输入正确的jdbc url, 例如:jdbc:mysql://127.0.0.1:3306/db_name", - "account_name_rule": "账号名不超过16个字符,且不能以下划线开始和结尾", + "account_name_rule": "使用 1–16 个不含空格的字符,支持中文/东亚兼容字符、字母、数字和下划线;下划线不能位于首尾", "favorite_name_rule": "只能包含中文、字母、数字、_, 不能超过48个字符", "invalid_data_format": "非法的数据格式" } diff --git a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json index 93a5ae2a8..322f3e4ce 100644 --- a/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json +++ b/hugegraph-hubble/hubble-fe/src/i18n/resources/zh-CN/modules/pages.json @@ -354,6 +354,7 @@ }, "empty": { "description": "这个 GraphSpace 已可用,但还没有图。创建第一张图后即可配置 Schema、导入和查询数据", + "read_only_description": "当前只读账号在此 GraphSpace 中暂无可访问的图", "create": "创建第一张图", "demo_prerequisite": "创建图后,选择该图并前往数据导入,即可快速构建 Demo 数据集。", "view_demo": "查看 Demo 数据入口", @@ -377,7 +378,8 @@ "name": "账号名", "remark": "备注", "level": "账号级别", - "resource": "资源权限", + "resource": "图空间", + "resource_all": "全部", "create_time": "创建时间", "is_superadmin": "是否为超级管理员", "permission": "管理权限" @@ -387,6 +389,26 @@ "SPACEADMIN": "空间管理员", "USER": "普通用户" }, + "permission_preset": { + "SUPER_ADMIN": "超级管理员", + "GS_READ_ONLY": "GraphSpace 只读", + "GS_READ_WRITE": "GraphSpace 读写", + "GS_ADMIN": "GraphSpace 管理员", + "preserve_mixed": "保留现有混合权限", + "mixed": "混合 GraphSpace 权限", + "unassigned": "未分配 GraphSpace 权限预设", + "legacy_custom": "旧版/自定义权限" + }, + "feedback": { + "save_failed": "账号未保存", + "save_retry": "请检查账号字段和 Server 连接后重试。", + "delete_retry": "账号删除失败,请刷新列表后重试。", + "password_permission_separate": "请分别保存密码和权限变更。", + "presets_unavailable": "GraphSpace 权限预设不可用", + "presets_unavailable_help": "当前部署未提供所需权限 API,账号将以无提升权限的普通账号创建", + "preset_edit_unavailable": "当前无法编辑权限", + "preset_edit_unavailable_help": "仍可修改用户资料和密码,现有权限将保持不变;升级当前连接的 HugeGraph REST API 至 0.72 或更高版本后可编辑权限" + }, "form": { "title_detail": "查看账号", "title_edit": "编辑账号", @@ -396,7 +418,7 @@ "id_help": "用于登录的唯一账号标识,创建后不可修改", "id_placeholder": "用户登录", "name": "账号名", - "name_help": "显示给其他管理员识别的账号名称,创建后仍可修改", + "name_help": "使用 1–16 个不含空格的字符,支持中文/东亚兼容字符、字母、数字和下划线", "name_placeholder": "账号名设置后可更改", "is_superadmin": "是否为超级管理员", "level": "账号级别", @@ -408,7 +430,11 @@ "default_password_help": "创建账号时设置的初始密码;用户登录后应尽快修改", "default_password_placeholder": "请输入 5–16 位初始密码", "permission": "管理权限", - "permission_help": "选择该账号可以管理的图空间;普通访问权限由 HugeGraph 角色控制" + "permission_help": "选择该账号可以管理的图空间;普通访问权限由 HugeGraph 角色控制", + "permission_preset": "权限预设", + "permission_preset_help": "选择清晰的预设语义,底层角色与资源目标由系统维护", + "graphspaces": "GraphSpace", + "graphspaces_help": "选择该预设覆盖的 GraphSpace" }, "space_access": { "global_tab": "全局账号", @@ -428,7 +454,8 @@ "id": "账号 ID", "name": "账号名", "roles": "角色", - "remove_confirm": "确定从该 GraphSpace 移除此成员吗?" + "remove_confirm": "确定从该 GraphSpace 移除此成员吗?", + "preset_unavailable": "当前 GraphSpace 不支持所选权限预设。" }, "role": { "add": "创建角色", @@ -486,7 +513,7 @@ "new_password_placeholder": "请输入新密码", "confirm_password_placeholder": "请再次输入密码", "password_mismatch": "两次密码不一致", - "account_name_rule": "账号名不超过16个字符,且不能以下划线开始和结尾" + "account_name_rule": "使用 1–16 个不含空格的字符,支持中文/东亚兼容字符、字母、数字和下划线;下划线不能位于首尾" } }, "role": { @@ -1078,6 +1105,7 @@ "empty_title": "创建第一个图 Schema", "empty_description": "建议先定义可复用属性,再创建顶点类型和边类型", "create_from_template": "根据模板创建 Schema", + "read_only_empty": "当前图尚无 Schema,需要读写或管理员账号才能创建。", "template_description": "选择内置示例或当前图空间已保存的模板,直接应用到当前图", "manual_title": "或手动创建 Schema", "builtin_templates": "内置示例", @@ -1128,7 +1156,7 @@ "read_only": { "page_title": "{{name}} - Schema 模板库", "title": "只读模板库", - "description": "可在此浏览内置模板与已保存模板;创建、保存、编辑和删除模板需要 PD 模式", + "description": "可在此浏览内置模板与已保存模板;当前连接或账号无法创建、保存、编辑和删除模板", "apply_to_graph": "前往 {{graph}} 应用模板", "choose_graph": "选择图并应用模板", "builtin": "内置" @@ -1216,6 +1244,8 @@ "node_id_copied": "节点 ID 已复制", "node_id_copy_failed": "无法复制节点 ID", "leader_role": "Leader 角色", + "leader": "Leader", + "follower": "Follower", "leader_shard": "{{count}} 个 Leader 分区", "leader_shards": "{{count}} 个 Leader 分区", "node_profile": "节点概况", @@ -1255,7 +1285,7 @@ "node_count": "{{count}} 个节点", "node_count_plural": "{{count}} 个节点", "more_nodes": "查看另外 {{count}} 个节点", - "cluster_facts": "集群事实", + "cluster_facts": "集群信息", "fact_stores_up": "在线 Store", "fact_pd_leader": "PD Leader", "fact_capacity": "容量", @@ -1305,8 +1335,8 @@ "metric_scope_backend": "后端指标由 Server 和 Store 节点提供,不会在 {{nodeType}} 节点采集。", "reason_upstream_timeout": "上游超时", "reason_upstream_unavailable": "上游不可用", - "reason_metrics_target_untrusted": "指标采集目标未通过信任校验,请检查 PD/Prometheus target 配置", - "reason_metrics_target_missing": "未发现 Store 指标采集目标,请检查 target 配置", + "reason_metrics_target_untrusted": "Store 指标来源不在信任列表中。请将其完整协议、主机和端口加入 operations.store.allowed_targets,重启 Hubble 后刷新。", + "reason_metrics_target_missing": "PD 未返回 Store 指标目标。请检查 PD target 发现和 Store REST 指标端口后刷新。", "reason_upstream_deadline": "指标采集超时", "reason_metrics_not_collected": "当前节点未采集该指标", "reason_topology_fields_unavailable": "拓扑未提供该字段", @@ -1314,6 +1344,7 @@ "reason_refresh_failed": "刷新失败", "reason_malformed_response": "响应格式异常", "reason_unsupported_version": "当前服务版本不支持", + "reason_unsupported_version_help": "请升级 HugeGraph 到提供该指标的版本。", "reason_deployment_mode_unsupported": "当前部署模式不支持", "metric_labels": { "basic": "内存与进程", @@ -1375,6 +1406,7 @@ "datasource_manage": "数据源管理", "operation_manage": "运维管理", "cluster_overview": "集群概览", + "cluster_overview_requires_pd": "集群概览依赖 PD,单机模式不可用;可前往节点页面查看 Server 状态。", "nodes": "节点", "advanced_monitoring": "外部高级 Dashboard", "operations_unavailable": "当前账号无可用的原生运维权限。", diff --git a/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.js b/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.js index a386045b3..18ebe299c 100644 --- a/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.js +++ b/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.js @@ -25,6 +25,7 @@ import {useTranslation} from 'react-i18next'; import * as api from '../../../api'; import {useOperationsCapabilities} from '../../../pages/Operations/capabilities'; +import {isPdEnabled} from '../../../utils/config'; import Item from '../Item'; import {normalizeDashboardUrl} from './dashboard'; @@ -36,6 +37,7 @@ const ConsoleItem = ({embedded = false}) => { capabilities, error: capabilitiesError, } = useOperationsCapabilities(); + const pdMode = isPdEnabled(); const [dashboard, setDashboard] = useState({status: 'loading', url: ''}); useEffect(() => { @@ -103,14 +105,16 @@ const ConsoleItem = ({embedded = false}) => { ? () => openDashboard(dashboard.url + path) : undefined, }); - const nativeItem = (titleKey, path, required) => { - const available = capabilities.includes(required); + const nativeItem = (titleKey, path, required, modeAvailable = true, modeReason = '') => { + const available = modeAvailable && capabilities.includes(required); const disabled = capabilitiesLoading || Boolean(capabilitiesError) || !available; return { title: t(titleKey), url: available ? path : '', disabled, - reason: disabled ? t('navigation_page.operations_unavailable') : '', + reason: disabled + ? (!modeAvailable && modeReason + ? modeReason : t('navigation_page.operations_unavailable')) : '', badge: disabled ? t('navigation_page.unavailable') : '', }; }; @@ -130,7 +134,9 @@ const ConsoleItem = ({embedded = false}) => { nativeItem( 'navigation_page.cluster_overview', '/operations/overview', - 'operations_health_read' + 'operations_health_read', + pdMode, + t('navigation_page.cluster_overview_requires_pd') ), nativeItem( 'navigation_page.nodes', diff --git a/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.test.js index 20e8df8b0..f65d52371 100644 --- a/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.test.js +++ b/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.test.js @@ -22,6 +22,7 @@ import * as api from '../../../api'; import ConsoleItem from './index'; import itemStyle from '../Item/index.module.scss'; import {useOperationsCapabilities} from '../../../pages/Operations/capabilities'; +import {isPdEnabled} from '../../../utils/config'; const mockMessageError = jest.fn(); @@ -33,6 +34,9 @@ jest.mock('../../../api', () => ({ jest.mock('../../../pages/Operations/capabilities', () => ({ useOperationsCapabilities: jest.fn(), })); +jest.mock('../../../utils/config', () => ({ + isPdEnabled: jest.fn(), +})); jest.mock('antd', () => ({ ...jest.requireActual('antd'), message: {error: (...args) => mockMessageError(...args)}, @@ -66,6 +70,7 @@ beforeEach(() => { ], error: null, }); + isPdEnabled.mockReturnValue(true); }); const renderConsole = () => render( @@ -116,6 +121,40 @@ test('links native operations independently of the optional Dashboard', async () })).toHaveAttribute('tabindex', '0'); }); +test('disables Cluster Overview outside PD mode while keeping Nodes available', async () => { + isPdEnabled.mockReturnValue(false); + api.auth.getDashboard.mockResolvedValue({ + status: 200, + data: {configured: false}, + }); + renderConsole(); + + expect(await screen.findByRole('button', { + name: 'navigation_page.cluster_overview', + })).toBeDisabled(); + expect(screen.getByRole('group', { + name: /navigation_page\.cluster_overview_requires_pd/, + })).toBeInTheDocument(); + expect(screen.getByRole('button', { + name: 'navigation_page.nodes', + })).toHaveAttribute('data-url', '/operations/nodes'); +}); + +test('keeps the capability reason when PD mode is available', async () => { + useOperationsCapabilities.mockReturnValue({ + loading: false, + capabilities: ['operations_topology_read'], + error: null, + }); + renderConsole(); + + expect(await screen.findByRole('group', { + name: /navigation_page\.operations_unavailable/, + })).toHaveAccessibleName( + expect.not.stringContaining('navigation_page.cluster_overview_requires_pd') + ); +}); + test('labels an unconfigured Dashboard instead of Coming Soon', async () => { api.auth.getDashboard.mockResolvedValue({ status: 200, diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js index 4fceadc32..1847ca1fd 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js @@ -16,17 +16,38 @@ * under the License. */ -import {Modal, Input, Form, Select, message, Spin, Switch} from 'antd'; +import {Alert, Modal, Input, Form, Select, message, Spin} from 'antd'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useTranslation} from 'react-i18next'; import * as api from '../../api'; import * as rules from '../../utils/rules'; import style from './index.module.scss'; import FormHelpLabel from '../../components/FormHelpLabel'; -import {getAccountLevel} from './level'; +import {accountErrorMessage} from './accountError'; +import {useAuthContext} from '../../auth/AuthContext'; +import { + getAccountPreset, + getAccountPresetLabelKey, + getPresetSpaces, + 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; +const toProfilePayload = values => ({ + user_name: values.user_name, + user_nickname: values.user_nickname, + user_password: values.user_password, + user_description: values.user_description, +}); +const sameSpaces = (left = [], right = []) => ( + [...left].sort().join('\u0000') === [...right].sort().join('\u0000') +); + +const loadAllGraphspaces = () => loadAllPages(api.manage.getGraphSpaceList); const HelpLabel = ({t, labelKey}) => ( { const {t} = useTranslation(); + const {context} = useAuthContext(); const [form] = Form.useForm(); const [graphspaceList, setGraphspaceList] = useState([]); const [detail, setDetail] = useState({}); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); + const [mutationError, setMutationError] = useState(null); const submitPending = useRef(false); const detailRequest = useRef(0); + const permissionPresetsSupported = !context + || context.capabilities?.includes('account_permission_presets'); + const permissionFieldsVisible = permissionPresetsSupported + && ['create', 'edit'].includes(op); + const preservesMixedPermissions = op === 'edit' + && getAccountPreset(detail) === null; + const presetOptions = [ + ...(preservesMixedPermissions ? [PRESERVE_PERMISSIONS] : []), + ...Object.values(PERMISSION_PRESETS), + ]; const title = { 'detail': t('account.form.title_detail'), @@ -60,18 +93,35 @@ const EditLayer = ({ }; const createUser = useCallback(values => { - return api.auth.addUser(values, PAGE_ERROR_CONFIG).then(res => { + const payload = permissionPresetsSupported + ? toPermissionPayload(values) + : toProfilePayload(values); + return api.auth.addUser(payload, PAGE_ERROR_CONFIG).then(res => { if (res.status === 200) { message.success(t('common.msg.create_success')); onCancel(); refresh(); return; } - message.error(t('common.msg.operation_failed')); - }).catch(() => message.error(t('common.msg.operation_failed'))); - }, [onCancel, refresh, t]); + throw res; + }); + }, [onCancel, permissionPresetsSupported, refresh, t]); const updateUser = useCallback(values => { - return api.auth.updateUser(data.id, values, PAGE_ERROR_CONFIG).then(res => { + const initialPreset = getAccountPreset(detail) ?? PRESERVE_PERMISSIONS; + const permissionsChanged = permissionPresetsSupported + && (values.permission_preset !== initialPreset + || !sameSpaces( + values.graphspaces, + getPresetSpaces(detail) + )); + if (values.user_password && permissionsChanged) { + throw new Error(t('account.feedback.password_permission_separate')); + } + const payload = permissionsChanged + && values.permission_preset !== PRESERVE_PERMISSIONS + ? toPermissionPayload(values) + : toProfilePayload(values); + return api.auth.updateUser(data.id, payload, PAGE_ERROR_CONFIG).then(res => { if (res.status === 200) { message.success(t('common.msg.update_success')); onCancel(); @@ -80,12 +130,16 @@ const EditLayer = ({ return; } - message.error(t('common.msg.operation_failed')); - }).catch(() => message.error(t('common.msg.operation_failed'))); - }, [onCancel, refresh, data.id, t]); + throw res; + }); + }, [onCancel, refresh, data.id, detail, permissionPresetsSupported, t]); const updateUserAuth = useCallback(values => { - return api.auth.updateAdminspace(data.id, values.adminSpaces, PAGE_ERROR_CONFIG).then(res => { + const payload = toPermissionPayload({ + ...values, + permission_preset: PERMISSION_PRESETS.GS_ADMIN, + }); + return api.auth.updateAdminspace(data.id, payload.adminSpaces, PAGE_ERROR_CONFIG).then(res => { if (res.status === 200) { message.success(t('common.msg.set_success')); onCancel(); @@ -94,8 +148,8 @@ const EditLayer = ({ return; } - message.error(t('common.msg.operation_failed')); - }).catch(() => message.error(t('common.msg.operation_failed'))); + throw res; + }); }, [data.id, onCancel, refresh, t]); const onFinish = useCallback(async () => { @@ -105,6 +159,7 @@ const EditLayer = ({ submitPending.current = true; setSubmitting(true); + setMutationError(null); try { const values = await form.validateFields(); if (op === 'create') { @@ -121,7 +176,11 @@ const EditLayer = ({ } catch (error) { if (!error || !error.errorFields) { - message.error(t('common.msg.operation_failed')); + const detail = accountErrorMessage( + error, t('account.feedback.save_retry') + ); + setMutationError(detail); + message.error(detail); } } finally { @@ -135,6 +194,7 @@ const EditLayer = ({ detailRequest.current += 1; setDetail({}); setGraphspaceList([]); + setMutationError(null); form.resetFields(); setLoading(false); return; @@ -143,8 +203,8 @@ const EditLayer = ({ const request = detailRequest.current + 1; detailRequest.current = request; setGraphspaceList([]); - if (op !== 'detail') { - api.manage.getGraphSpaceList(undefined, PAGE_ERROR_CONFIG).then(res => { + if (op !== 'detail' && (permissionPresetsSupported || op === 'auth')) { + loadAllGraphspaces().then(res => { if (detailRequest.current !== request) { return; } @@ -176,7 +236,13 @@ const EditLayer = ({ if (res.status === 200) { if (op !== 'detail') { - form.setFieldsValue(res.data); + form.setFieldsValue({ + ...res.data, + permission_preset: getAccountPreset(res.data) ?? PRESERVE_PERMISSIONS, + graphspaces: op === 'auth' + ? (res.data?.adminSpaces ?? []) + : getPresetSpaces(res.data), + }); } setDetail(res.data); return; @@ -204,7 +270,7 @@ const EditLayer = ({ form.resetFields(); setLoading(false); } - }, [visible, data.id, form, op, t]); + }, [visible, data.id, form, op, permissionPresetsSupported, t]); if (op !== 'detail' && !allowedOperations[op]) { return null; @@ -233,17 +299,22 @@ const EditLayer = ({ {detail.user_nickname} - - {detail.is_superadmin ? t('common.yes') : t('common.no')} - - - {t(`account.level.${getAccountLevel(detail)}`)} + + {t(`account.permission_preset.${getAccountPresetLabelKey( + detail, permissionPresetsSupported + )}`)} {detail.user_description} - - {detail.adminSpaces ? detail.adminSpaces.join(',') : ''} + + {getPresetSpaces(detail).join(', ')} {detail.user_create} @@ -263,6 +334,30 @@ const EditLayer = ({ width={600} > + {!permissionPresetsSupported && ['create', 'edit'].includes(op) && ( + + )} + {mutationError && ( + + )}
} name="user_nickname" - rules={[rules.required(), rules.isAccountName]} + rules={[rules.isAccountName]} validateFirst > - } - name="is_superadmin" - valuePropName="checked" - > - - - } - name="user_description" - > - - } name="user_password" @@ -315,21 +397,82 @@ const EditLayer = ({ autoComplete="new-password" /> + {permissionFieldsVisible && ( + } + name="permission_preset" + rules={[rules.required()]} + > + + + {permissionFieldsVisible && ( + + {({getFieldValue}) => ( + [ + PERMISSION_PRESETS.SUPER_ADMIN, + PRESERVE_PERMISSIONS, + ].includes( + getFieldValue('permission_preset') + ) + ? null + : ( + + )} + name="graphspaces" + rules={[rules.required()]} + > + - + <> + } + name="permission_preset" + rules={[rules.required()]} + > + + + )}
diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js index 873e574b5..a1c53f90f 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/SpaceAccess.js @@ -35,17 +35,70 @@ import {useTranslation} from 'react-i18next'; import * as api from '../../api'; import TableHeader from '../../components/TableHeader'; import {useAuthContext} from '../../auth/AuthContext'; - -const PAGE_ERROR_CONFIG = {suppressBusinessErrorToast: true}; -const PAGE_PARAMS = {query: '', page_no: 1, page_size: 200}; -const PERMISSIONS = ['READ', 'WRITE', 'DELETE', 'EXECUTE']; -const DEFAULT_RESOURCES = JSON.stringify([ - {type: 'GREMLIN', label: '*', properties: null}, -], null, 2); +import {PERMISSION_PRESETS} from './permissionPresets'; +import {loadAllPages, PAGE_ERROR_CONFIG} from './pagedRecords'; const responseRecords = response => response?.data?.records ?? []; -const responseList = response => (Array.isArray(response?.data) ? response.data : []); -const accessRowKey = row => `${row.role_id}:${row.target_id}`; +const showMutationError = (error, t) => { + const response = error?.response ?? error; + const detail = response?.data?.message ?? response?.message; + message.error(detail + ? `${t('common.msg.operation_failed')} (${detail})` + : t('common.msg.operation_failed')); +}; +const adminRole = { + role_id: PERMISSION_PRESETS.GS_ADMIN, + permission_preset: PERMISSION_PRESETS.GS_ADMIN, +}; + +const mergeMembersAndAdmins = (members, admins) => { + const rows = new Map(members.map(member => [ + member.user_id, + {...member, member_roles: member.roles ?? []}, + ])); + admins.forEach(admin => { + const userId = admin.id ?? admin.user_id; + const existing = rows.get(userId) ?? {}; + rows.set(userId, { + ...existing, + user_id: userId, + user_name: admin.name ?? admin.user_name ?? existing.user_name, + member_roles: existing.roles ?? existing.member_roles ?? [], + roles: [...(existing.roles ?? existing.member_roles ?? []), adminRole], + is_space_admin: true, + }); + }); + return Array.from(rows.values()); +}; + +const rolePreset = role => { + const explicit = role?.permission_preset ?? role?.permissionPreset; + return Object.values(PERMISSION_PRESETS).includes(explicit) + ? explicit + : null; +}; + +const rolesPreset = roles => { + const values = roles ?? []; + const presets = values.map(rolePreset); + if (presets.some(preset => preset === null)) { + return null; + } + if (values.some(role => (role?.permission_preset ?? role?.permissionPreset) === PERMISSION_PRESETS.GS_ADMIN)) { + return PERMISSION_PRESETS.GS_ADMIN; + } + return values.length > 0 && presets.every(Boolean) && new Set(presets).size === 1 ? presets[0] : null; +}; + +const roleLabel = (role, t) => { + const preset = rolePreset(role); + if (preset) { + return t(`account.permission_preset.${preset}`); + } + const name = role?.role_name ?? role?.name; + const legacy = t('account.permission_preset.legacy_custom'); + return name ? `${name} · ${legacy}` : legacy; +}; const RowAction = ({row, onAction, children}) => { const handleClick = useCallback(() => onAction(row), [onAction, row]); @@ -122,15 +175,8 @@ const SpaceAccess = () => { const contextVersion = context?.context_version; const scopes = context?.scopes ?? {}; const memberActions = context?.actions?.members ?? []; - const roleActions = context?.actions?.roles ?? []; - const authorizationActions = context?.actions?.authorizations ?? []; const canAddMember = memberActions.includes('add'); const canRemoveMember = memberActions.includes('remove'); - const canCreateRole = roleActions.includes('create'); - const canUpdateRole = roleActions.includes('update'); - const canDeleteRole = roleActions.includes('delete'); - const canGrant = authorizationActions.includes('grant'); - const canRevoke = authorizationActions.includes('revoke'); const [selectedSpace, setSelectedSpace] = useState(''); const [allSpaces, setAllSpaces] = useState([]); const [spacesLoading, setSpacesLoading] = useState(false); @@ -138,14 +184,8 @@ const SpaceAccess = () => { const [spacesRevision, setSpacesRevision] = useState(0); const spacesRequest = useRef(null); const [memberDialog, setMemberDialog] = useState(null); - const [roleDialog, setRoleDialog] = useState(null); - const [targetDialog, setTargetDialog] = useState(null); - const [accessDialog, setAccessDialog] = useState(null); const [submitting, setSubmitting] = useState(false); const [memberForm] = Form.useForm(); - const [roleForm] = Form.useForm(); - const [targetForm] = Form.useForm(); - const [accessForm] = Form.useForm(); const scopedSpaces = useMemo( () => scopes.admin_graphspaces ?? [], @@ -164,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; @@ -194,37 +234,33 @@ 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 loadRoles = useCallback(space => api.auth.getSpaceRoles( - space, PAGE_PARAMS, PAGE_ERROR_CONFIG + const loadMembers = useCallback(space => loadAllPages( + (params, config) => api.auth.getSpaceMembers(space, params, config), + {params: {query: ''}} ), []); - const loadTargets = useCallback(space => api.auth.getSpaceTargets( - space, PAGE_PARAMS, PAGE_ERROR_CONFIG - ), []); - const loadAccesses = useCallback(space => api.auth.getSpaceAccesses( - space, {}, PAGE_ERROR_CONFIG + const loadAdmins = useCallback(space => loadAllPages( + (params, config) => api.auth.getSpaceAdmins(space, params, config), + {params: {query: ''}} ), []); const members = useScopedResource( graphSpace, contextVersion, loadMembers, responseRecords ); - const roles = useScopedResource( - graphSpace, contextVersion, loadRoles, responseRecords - ); - const targets = useScopedResource( - graphSpace, contextVersion, loadTargets, responseRecords - ); - const accesses = useScopedResource( - graphSpace, contextVersion, loadAccesses, responseList + const admins = useScopedResource(graphSpace, contextVersion, loadAdmins, responseRecords ); + const visibleMembers = { + data: mergeMembersAndAdmins(members.data, admins.data), + loading: members.loading || admins.loading, + error: members.error || admins.error, + retry: () => { + members.retry(); + admins.retry(); + }, + }; const refreshAll = useCallback(() => { members.retry(); - roles.retry(); - targets.retry(); - accesses.retry(); - }, [accesses, members, roles, targets]); + admins.retry(); + }, [admins, members]); const runMutation = useCallback(async (operation, close) => { if (submitting) { @@ -234,7 +270,7 @@ const SpaceAccess = () => { try { const response = await operation(); if (response?.status !== 200) { - message.error(t('common.msg.operation_failed')); + showMutationError(response, t); return; } message.success(t('common.msg.success')); @@ -242,7 +278,7 @@ const SpaceAccess = () => { refreshAll(); } catch (error) { - message.error(t('common.msg.operation_failed')); + showMutationError(error, t); } finally { setSubmitting(false); @@ -252,120 +288,27 @@ const SpaceAccess = () => { const openMember = useCallback(row => { memberForm.setFieldsValue({ user_id: row?.user_id, - roles: row?.roles?.map(role => role.role_id) ?? [], + username: row?.user_name, + permission_preset: rolesPreset(row?.roles), }); setMemberDialog(row ?? {}); }, [memberForm]); - const openRole = useCallback(row => { - roleForm.setFieldsValue({ - role_name: row?.role_name ?? row?.role_nickname, - role_description: row?.role_description, - }); - setRoleDialog(row ?? {}); - }, [roleForm]); - const openTarget = useCallback(row => { - targetForm.setFieldsValue({ - target_name: row?.target_name, - target_graph: row?.target_graph, - target_description: row?.target_description, - target_resources: row?.target_resources - ? JSON.stringify(row.target_resources, null, 2) - : DEFAULT_RESOURCES, - }); - setTargetDialog(row ?? {}); - }, [targetForm]); - const openAccess = useCallback(row => { - accessForm.setFieldsValue({ - role_id: row?.role_id, - target_id: row?.target_id, - permissions: row?.permissions ?? [], - }); - setAccessDialog(row ?? {}); - }, [accessForm]); - const closeMember = useCallback(() => { setMemberDialog(null); memberForm.resetFields(); }, [memberForm]); - const closeRole = useCallback(() => { - setRoleDialog(null); - roleForm.resetFields(); - }, [roleForm]); - const closeTarget = useCallback(() => { - setTargetDialog(null); - targetForm.resetFields(); - }, [targetForm]); - const closeAccess = useCallback(() => { - setAccessDialog(null); - accessForm.resetFields(); - }, [accessForm]); - const submitMember = useCallback(values => { - const roleLookup = new Map(roles.data.map(role => [role.id, role])); - const payload = { - user_id: values.user_id, - roles: values.roles.map(id => ({ - role_id: id, - role_name: roleLookup.get(id)?.role_name - ?? roleLookup.get(id)?.role_nickname ?? id, - })), - }; - const operation = memberDialog?.user_id - ? () => api.auth.updateSpaceMember( - graphSpace, memberDialog.user_id, payload, PAGE_ERROR_CONFIG - ) - : () => api.auth.addSpaceMember( - graphSpace, payload, PAGE_ERROR_CONFIG - ); - runMutation(operation, closeMember); - }, [closeMember, graphSpace, memberDialog, roles.data, runMutation]); - - const submitRole = useCallback(values => { - const operation = roleDialog?.id - ? () => api.auth.updateSpaceRole( - graphSpace, roleDialog.id, values, PAGE_ERROR_CONFIG - ) - : () => api.auth.addSpaceRole( - graphSpace, values, PAGE_ERROR_CONFIG - ); - runMutation(operation, closeRole); - }, [closeRole, graphSpace, roleDialog, runMutation]); - - const submitTarget = useCallback(values => { - let resources; - try { - resources = JSON.parse(values.target_resources); - if (!Array.isArray(resources)) { - throw new Error('resources must be an array'); - } - } - catch (error) { - targetForm.setFields([{ - name: 'target_resources', - errors: [t('account.space_access.target.resources_invalid')], - }]); - return; - } - const payload = { - target_name: values.target_name, - target_graph: values.target_graph, - target_description: values.target_description, - target_resources: resources, - }; - const operation = targetDialog?.id - ? () => api.auth.updateSpaceTarget( - graphSpace, targetDialog.id, payload, PAGE_ERROR_CONFIG - ) - : () => api.auth.addSpaceTarget( - graphSpace, payload, PAGE_ERROR_CONFIG - ); - runMutation(operation, closeTarget); - }, [closeTarget, graphSpace, runMutation, t, targetDialog, targetForm]); - - const submitAccess = useCallback(values => { - runMutation(() => api.auth.saveSpaceAccess(graphSpace, values, - PAGE_ERROR_CONFIG), closeAccess); - }, [closeAccess, graphSpace, runMutation]); + runMutation( + () => api.auth.setSpacePreset( + graphSpace, + values.user_id ?? values.username, + values.username, + values.permission_preset, + PAGE_ERROR_CONFIG + ), + closeMember + ); + }, [closeMember, graphSpace, runMutation]); const confirmDelete = useCallback((title, operation) => { Modal.confirm({ @@ -381,34 +324,13 @@ const SpaceAccess = () => { graphSpace, row.user_id, PAGE_ERROR_CONFIG ) ), [confirmDelete, graphSpace, t]); - const editRole = useCallback(row => openRole(row), [openRole]); - const deleteRole = useCallback(row => confirmDelete( - t('account.space_access.role.delete_confirm'), - () => api.auth.deleteSpaceRole(graphSpace, row.id, PAGE_ERROR_CONFIG) - ), [confirmDelete, graphSpace, t]); - const editTarget = useCallback(row => openTarget(row), [openTarget]); - const deleteTarget = useCallback(row => confirmDelete( - t('account.space_access.target.delete_confirm'), - () => api.auth.deleteSpaceTarget(graphSpace, row.id, PAGE_ERROR_CONFIG) - ), [confirmDelete, graphSpace, t]); - const editAccess = useCallback(row => openAccess(row), [openAccess]); - const deleteAccess = useCallback(row => confirmDelete( - t('account.space_access.authorization.delete_confirm'), - () => api.auth.deleteSpaceAccess( - graphSpace, row.role_id, row.target_id, PAGE_ERROR_CONFIG - ) - ), [confirmDelete, graphSpace, t]); const addMember = useCallback(() => openMember(), [openMember]); - const addRole = useCallback(() => openRole(), [openRole]); - const addTarget = useCallback(() => openTarget(), [openTarget]); - const addAccess = useCallback(() => openAccess(), [openAccess]); + const canManageMember = row => scopes.all_graphspaces + || !row.is_space_admin; const retrySpaces = useCallback( () => setSpacesRevision(value => value + 1), [] ); const submitMemberForm = useCallback(() => memberForm.submit(), [memberForm]); - const submitRoleForm = useCallback(() => roleForm.submit(), [roleForm]); - const submitTargetForm = useCallback(() => targetForm.submit(), [targetForm]); - const submitAccessForm = useCallback(() => accessForm.submit(), [accessForm]); const memberColumns = [ {title: t('account.space_access.member.id'), dataIndex: 'user_id'}, @@ -417,19 +339,19 @@ const SpaceAccess = () => { title: t('account.space_access.member.roles'), dataIndex: 'roles', render: value => value?.map(role => ( - {role.role_name} + {roleLabel(role, t)} )), }, ...((canAddMember || canRemoveMember) ? [{ title: t('common.operation'), render: row => ( - {canAddMember && ( + {canAddMember && canManageMember(row) && ( {t('common.action.edit')} )} - {canRemoveMember && ( + {canRemoveMember && canManageMember(row) && ( {t('common.action.delete')} @@ -439,86 +361,6 @@ const SpaceAccess = () => { }] : []), ]; - const roleColumns = [ - {title: t('account.space_access.role.name'), dataIndex: 'role_name'}, - { - title: t('account.space_access.role.description'), - dataIndex: 'role_description', - }, - ...((canUpdateRole || canDeleteRole) ? [{ - title: t('common.operation'), - render: row => ( - - {canUpdateRole && ( - - {t('common.action.edit')} - - )} - {canDeleteRole && ( - - {t('common.action.delete')} - - )} - - ), - }] : []), - ]; - - const targetColumns = [ - {title: t('account.space_access.target.name'), dataIndex: 'target_name'}, - {title: t('account.space_access.target.graph'), dataIndex: 'target_graph'}, - { - title: t('account.space_access.target.description'), - dataIndex: 'target_description', - }, - ...((canGrant || canRevoke) ? [{ - title: t('common.operation'), - render: row => ( - - {canGrant && ( - - {t('common.action.edit')} - - )} - {canRevoke && ( - - {t('common.action.delete')} - - )} - - ), - }] : []), - ]; - - const accessColumns = [ - {title: t('account.space_access.role.name'), dataIndex: 'role_name'}, - {title: t('account.space_access.target.name'), dataIndex: 'target_name'}, - { - title: t('account.space_access.authorization.permissions'), - dataIndex: 'permissions', - render: value => value?.map(permission => ( - {permission} - )), - }, - ...((canGrant || canRevoke) ? [{ - title: t('common.operation'), - render: row => ( - - {canGrant && ( - - {t('common.action.edit')} - - )} - {canRevoke && ( - - {t('common.action.delete')} - - )} - - ), - }] : []), - ]; - const table = (resource, columns, rowKey, addLabel, onAdd, canAdd) => ( <> @@ -578,39 +420,11 @@ const SpaceAccess = () => { key: 'members', label: t('account.space_access.tabs.members'), children: table( - members, memberColumns, 'user_id', + visibleMembers, memberColumns, 'user_id', t('account.space_access.member.add'), addMember, canAddMember ), }, - { - key: 'roles', - label: t('account.space_access.tabs.roles'), - children: table( - roles, roleColumns, 'id', - t('account.space_access.role.add'), - addRole, canCreateRole - ), - }, - { - key: 'targets', - label: t('account.space_access.tabs.targets'), - children: table( - targets, targetColumns, 'id', - t('account.space_access.target.add'), - addTarget, canGrant - ), - }, - { - key: 'authorizations', - label: t('account.space_access.tabs.authorizations'), - children: table( - accesses, accessColumns, - accessRowKey, - t('account.space_access.authorization.add'), - addAccess, canGrant - ), - }, ]} /> @@ -623,145 +437,50 @@ const SpaceAccess = () => { destroyOnClose >
+ {memberDialog?.user_id ? ( + <> + + + + + + ) : ( + + + + )} - - - - - - - - -
- - - -
- - - - - - - - - - - - -
-
- - -
- ({ - value: target.id, - label: target.target_name, - }))} - /> - - - + + {templateLoadError ? ( + + {t('schema.image_view.retry_templates')} + + )} /> - {!savedOptions.length && ( - - {t('schema.image_view.no_saved_templates')} + ) : ( + + + {t('schema.image_view.template_picker_help')} - )} - - )} - - +