This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git
commit de0fd460c07b35d1ae16f6671580164bb0a84372 Author: dark <[email protected]> AuthorDate: Tue Aug 18 22:49:44 2026 +0800 fix(hubble): secure scoped GraphSpace workflows - hide backend bootstrap details - enforce direct and indirect GraphSpace scope - isolate query records and account security updates - retain non-auth verification evidence --- .../images/pr27/15-standalone-nonauth-visual.png | Bin 0 -> 90401 bytes .../images/pr27/18-standalone-nonauth-visual.png | Bin 0 -> 93329 bytes .../java/org/apache/hugegraph/common/Constant.java | 2 + .../hugegraph/controller/BaseController.java | 38 ++++- .../hugegraph/controller/ConfigController.java | 6 +- .../controller/graphs/GraphsController.java | 10 +- .../controller/ingest/IngestController.java | 100 +++++++++++- .../controller/load/FileMappingController.java | 11 ++ .../controller/load/FileUploadController.java | 4 + .../controller/load/JobManagerController.java | 6 +- .../controller/load/LoadTaskController.java | 7 + .../controller/query/ExecuteHistoryController.java | 9 +- .../query/GremlinCollectionController.java | 30 +++- .../controller/space/GraphSpaceController.java | 35 +++-- .../controller/space/VermeerController.java | 2 +- .../hugegraph/handler/CustomInterceptor.java | 44 +++++- .../apache/hugegraph/handler/LoginInterceptor.java | 3 +- .../service/auth/GraphSpaceUserService.java | 3 +- .../apache/hugegraph/service/auth/UserService.java | 87 +++++----- .../hugegraph/service/load/JobManagerService.java | 34 ++++ .../service/query/ExecuteHistoryService.java | 20 ++- .../service/query/GremlinCollectionService.java | 25 ++- .../hugegraph/service/space/GraphSpaceService.java | 73 +++++++-- .../controller/ingest/IngestControllerTest.java | 118 ++++++++++++++ .../controller/space/GraphSpaceControllerTest.java | 68 ++++++++ .../service/auth/GraphSpaceUserServiceTest.java | 10 +- .../service/space/GraphSpaceServiceTest.java | 31 ++++ .../apache/hugegraph/unit/AuthSecurityTest.java | 127 +++++++++++++++ .../hugegraph/unit/ConfigControllerTest.java | 49 ++++++ .../hugegraph/unit/FileMappingDeletionTest.java | 27 +++- .../unit/GraphsControllerCanonicalTest.java | 52 +++++- .../hugegraph/unit/LoaderScopeControllerTest.java | 29 +++- .../unit/UserServiceCompatibilityTest.java | 175 +++++++++++++++++---- .../src/api/request-error-semantics.test.js | 28 ++++ hugegraph-hubble/hubble-fe/src/api/request.js | 13 +- .../src/i18n/resources/en-US/modules/pages.json | 2 + .../src/i18n/resources/zh-CN/modules/pages.json | 2 + .../hubble-fe/src/pages/Account/EditLayer.js | 17 +- .../pages/Account/account-edit-recovery.test.js | 73 +++++++++ .../hubble-fe/src/pages/Operations/NodeDetail.js | 17 +- .../src/pages/Operations/NodeDetail.test.js | 4 + 41 files changed, 1230 insertions(+), 161 deletions(-) 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..63be9e458 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,8 @@ public final class Constant { public static final String TOKEN_KEY = "auth_token"; public static final String USERNAME_KEY = "username"; + 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/controller/BaseController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/BaseController.java index 82446d910..1f7634785 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 @@ -28,6 +28,7 @@ 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; @@ -65,6 +66,8 @@ public abstract class BaseController { 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"; @@ -146,12 +149,14 @@ public abstract class BaseController { 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.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); } @@ -160,6 +165,7 @@ public abstract class BaseController { } protected HugeClient requireAccountManager() { + this.requireAuthenticatedAuthorization(); HugeClient client = this.authClient(null, null); String level = this.userService.userLevel(client, this.getUser()); if (!"ADMIN".equals(level)) { @@ -169,6 +175,7 @@ public abstract class BaseController { } protected HugeClient requireGraphSpaceManager(String graphSpace) { + this.requireAuthenticatedAuthorization(); HugeClient client = this.authClient(null, null); if (!this.userService.isSuperAdmin(client) && !this.userService.isAssignSpaceAdmin(client, graphSpace)) { @@ -181,6 +188,7 @@ public abstract class BaseController { 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); @@ -189,6 +197,7 @@ public abstract class BaseController { 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"); @@ -198,6 +207,7 @@ public abstract class BaseController { } protected HugeClient requireGraphSpaceAdministrator() { + this.requireAuthenticatedAuthorization(); HugeClient client = this.authClient(null, null); if (!this.userService.isSuperAdmin(client)) { throw new ForbiddenException( @@ -294,10 +304,36 @@ public abstract class BaseController { 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 7815e82e8..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,12 +39,8 @@ public class ConfigController { @GetMapping public Map<String, Object> getConfig() { Map<String, Object> result = new HashMap<>(); - boolean pdEnabled = config.get(HubbleOptions.PD_ENABLED); - result.put("pd_enabled", pdEnabled); + result.put("pd_enabled", config.get(HubbleOptions.PD_ENABLED)); result.put("auth_enabled", config.get(HubbleOptions.AUTH_ENABLED)); - if (!pdEnabled) { - result.put("server_url", config.get(HubbleOptions.SERVER_URL)); - } return result; } } 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 class GraphsController extends BaseController { 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 class IngestController extends BaseController { 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<String, Object> data = new HashMap<>(); @@ -299,9 +300,7 @@ public class IngestController extends BaseController { @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<JobManager> page = jobManagerService.listAll(pageNo, pageSize, query); + IPage<JobManager> page = this.visibleJobPage(pageNo, pageSize, query); IPage<TaskVO> result = page.convert(job -> { TaskVO vo = new TaskVO(); @@ -367,11 +366,18 @@ public class IngestController extends BaseController { 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 class IngestController extends BaseController { 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 class IngestController extends BaseController { 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 class IngestController extends BaseController { @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<LoadTask> tasks = loadTaskService.taskListByJob(taskId); // Manual pagination @@ -453,11 +467,18 @@ public class IngestController extends BaseController { 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 class IngestController extends BaseController { @GetMapping("/metrics/task") public Response metricsTask() { - List<JobManager> all = jobManagerService.listAll(); + List<JobManager> all = this.visibleJobs(""); all.forEach(jobManagerService::refreshStatus); long runningOnce = 0; @@ -506,6 +527,75 @@ public class IngestController extends BaseController { // ===== Helpers ===== + private IPage<JobManager> visibleJobPage(int pageNo, int pageSize, + String query) { + Set<String> graphSpaces = this.visibleGraphSpaces(); + IPage<JobManager> page = this.jobManagerService.listByGraphSpaces( + graphSpaces, pageNo, pageSize, query); + page.getRecords().forEach(jobManagerService::refreshStatus); + return page; + } + + private List<JobManager> visibleJobs(String query) { + Set<String> graphSpaces = this.visibleGraphSpaces(); + List<JobManager> 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<String> 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/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 class FileMappingController extends BaseController { @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 class FileMappingController extends BaseController { public void clear(@PathVariable("graphspace") String graphSpace, @PathVariable("graph") String graph, @PathVariable("jobId") int jobId) { + this.requireGraphSpaceWrite(graphSpace); List<FileMapping> mappings = this.service.listByJob(graphSpace, graph, jobId); Set<Integer> fileIds = new HashSet<>(); @@ -143,6 +145,7 @@ public class FileMappingController extends BaseController { @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 class FileMappingController extends BaseController { @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 class FileMappingController extends BaseController { @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 class FileMappingController extends BaseController { @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 class FileMappingController extends BaseController { @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 class FileMappingController extends BaseController { @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 class FileMappingController extends BaseController { @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 class FileMappingController extends BaseController { @PathVariable("graph") String graph, @PathVariable("jobId") int jobId, @RequestBody LoadParameter newEntity) { + this.requireGraphSpaceWrite(graphSpace); this.checkLoadParameter(newEntity); List<FileMapping> mappings = this.service.listByJob(graphSpace, graph, jobId); @@ -327,6 +337,7 @@ public class FileMappingController extends BaseController { 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 class FileUploadController extends BaseController { @PathVariable("jobId") int jobId, @RequestParam("names") List<String> 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 class FileUploadController extends BaseController { @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 class FileUploadController extends BaseController { @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 class FileUploadController extends BaseController { 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 lombok.extern.log4j.Log4j2; 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 @@ import java.util.List; @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 class JobManagerController { 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 class JobManagerController { 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 class JobManagerController { @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 class LoadTaskController extends BaseController { @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 class LoadTaskController extends BaseController { @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 class LoadTaskController extends BaseController { @PathVariable("jobId") int jobId, @RequestParam("file_mapping_ids") List<Integer> fileIds) { + this.requireGraphSpaceWrite(graphSpace); GraphConnection connection = new GraphConnection(); connection.setCluster(config.get(HubbleOptions.PD_CLUSTER)); @@ -205,6 +208,7 @@ public class LoadTaskController extends BaseController { @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 class LoadTaskController extends BaseController { @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 class LoadTaskController extends BaseController { @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 class LoadTaskController extends BaseController { @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/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 class ExecuteHistoryController extends GremlinController { @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 class GremlinCollectionController extends GremlinController { } @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 class GremlinCollectionController extends GremlinController { } @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/space/GraphSpaceController.java b/hugegraph-hubble/hubble-be/src/main/java/org/apache/hugegraph/controller/space/GraphSpaceController.java index e3a8e2d6a..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 @@ -81,10 +81,14 @@ public class GraphSpaceController extends BaseController { } HugeClient client = this.authClient(null, null); - List<String> graphSpaces = - this.authMode != null && this.authMode.anonymous() ? - this.graphSpaceService.listAnonymous(client) : - this.graphSpaceService.listAll(client); + List<String> 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); } @@ -133,10 +137,16 @@ public class GraphSpaceController extends BaseController { return ImmutableMap.of("auth", false); } HugeClient client = this.authClient(null, null); - boolean isAuth = - this.authMode != null && this.authMode.anonymous() ? - this.graphSpaceService.isAuthForAnonymous(client, graphSpace) : - this.graphSpaceService.isAuth(client, graphSpace); + 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); } @@ -153,9 +163,12 @@ public class GraphSpaceController extends BaseController { if (this.authMode != null && this.authMode.anonymous()) { return this.graphSpaceService.getAnonymous(client, graphspace); } - // Get GraphSpace Info - return graphSpaceService.toView( - graphSpaceService.getWithAdmins(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/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 class VermeerController extends BaseController { 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<String, Object> graphInfo = HubbleUtil.uncheckedCast( client.vermeer().getGraphInfoByName(vGraph).get("graph")); 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 09e88c3ec..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 @@ -29,6 +29,7 @@ import javax.servlet.http.HttpSession; //import org.apache.hugegraph.license.LicenseVerifier; // TODO C Remove Licence import org.apache.hugegraph.service.HugeClientPoolService; import org.apache.hugegraph.service.auth.AuthModeService; +import org.apache.hugegraph.service.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; @@ -36,8 +37,10 @@ import org.springframework.util.StringUtils; 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; @@ -52,6 +55,10 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { 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+/.+"); @@ -134,7 +141,9 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { String[] scope = this.requestScope(uri); String graphSpace = scope[0]; String graph = scope[1]; - if (this.authMode != null && this.authMode.anonymous()) { + boolean anonymous = this.authMode != null && + this.authMode.anonymous(); + if (anonymous) { client = unauthClient(graphSpace, graph); } else if (!this.hasAuthSession(request)) { return; @@ -143,11 +152,35 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { (String) request.getSession().getAttribute(Constant.TOKEN_KEY); 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"); @@ -192,8 +225,13 @@ public class CustomInterceptor extends HandlerInterceptorAdapter { String[] parts = uri.split("/"); for (int i = 0; i < parts.length; i++) { if ("graphspaces".equals(parts[i]) && i < parts.length - 1) { - graphSpace = parts[i + 1]; - graphSpace = decodeSegment(graphSpace); + 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]; 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 ae3baee1e..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 @@ -90,7 +90,6 @@ public class LoginInterceptor extends HandlerInterceptorAdapter { return false; } String scopedAuth = apiPath.substring(0, graphSpaceEnd) + "/auth"; - return apiPath.equals(scopedAuth) || - apiPath.startsWith(scopedAuth + "/"); + return apiPath.startsWith(scopedAuth + "/"); } } 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 325b6e382..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 @@ -470,7 +470,8 @@ public class GraphSpaceUserService extends AuthService { public boolean hasGraphSpaceAccess(HugeClient client, String graphSpace, String username) { if (!client.supportsDefaultRole()) { - return false; + return client.auth().listSpaceMember(graphSpace) + .contains(username); } return client.graphSpace().checkDefaultRole( graphSpace, username, "analyst") || 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 0d44836db..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 @@ -24,6 +24,7 @@ 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; @@ -235,15 +236,8 @@ public class UserService extends AuthService { } User newUser = client.auth().createUser(user); - List<String> attemptedAdminSpaces = new ArrayList<>(); boolean superAdminAttempted = false; try { - if (!permissionPresets && ue.getAdminSpaces() != null) { - for (String graphspace : ue.getAdminSpaces()) { - attemptedAdminSpaces.add(graphspace); - client.auth().addSpaceAdmin(ue.getName(), graphspace); - } - } if (permissionPresets) { this.graphSpaceUserService .applyPermissionPresetsForNewAccount( @@ -251,13 +245,12 @@ public class UserService extends AuthService { ue.getGraphspacePermissions(), ue.getPermissionPreset()); } - if (newUser != null && ue.isSuperadmin()) { + if (permissionPresets && newUser != null && ue.isSuperadmin()) { superAdminAttempted = true; client.auth().addSuperAdmin(ue.getName()); } } catch (RuntimeException error) { this.rollbackNewAccount(client, newUser, ue.getName(), - attemptedAdminSpaces, superAdminAttempted, error); throw error; } @@ -265,19 +258,12 @@ public class UserService extends AuthService { private void rollbackNewAccount(HugeClient client, User user, String username, - List<String> attemptedAdminSpaces, boolean superAdminAttempted, RuntimeException failure) { if (superAdminAttempted) { this.suppressRollback( () -> client.auth().delSuperAdmin(username), failure); } - for (int i = attemptedAdminSpaces.size() - 1; i >= 0; i--) { - String graphSpace = attemptedAdminSpaces.get(i); - this.suppressRollback( - () -> client.auth().delSpaceAdmin(username, graphSpace), - failure); - } if (user != null) { this.suppressRollback( () -> client.auth().deleteUser(user.id()), failure); @@ -590,6 +576,9 @@ public class UserService extends AuthService { 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()); @@ -604,23 +593,30 @@ public class UserService extends AuthService { this.updateModernPermissionAccount(hugeClient, user, userEntity); return; } - if (!permissionPresets) { - updateAdminSpace(hugeClient, userEntity.getName(), - userEntity.getAdminSpaces()); + hugeClient.auth().updateUser(user); + } + + 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 (!permissionPresets) { - String username = userEntity.getName(); - boolean currentSuperAdmin = - isSuperAdmin(hugeClient, user.id().toString()); - if (currentSuperAdmin && !userEntity.isSuperadmin()) { - hugeClient.auth().delSuperAdmin(username); - } - if (!currentSuperAdmin && userEntity.isSuperadmin()) { - hugeClient.auth().addSuperAdmin(username); + if (user.getAdminSpaces() != null) { + List<String> current = this.listAdminSpace(client, user.getName()); + if (!new HashSet<>(current).equals( + new HashSet<>(user.getAdminSpaces()))) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); } } - - hugeClient.auth().updateUser(user); + if (user.hasSuperadmin() && + this.isSuperAdmin(client, user.getId()) != user.isSuperadmin()) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); + } } private void updateModernPermissionAccount(HugeClient client, User user, @@ -683,6 +679,15 @@ public class UserService extends AuthService { 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(); @@ -693,6 +698,11 @@ public class UserService extends AuthService { } 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( @@ -779,22 +789,13 @@ public class UserService extends AuthService { if (adminspaces == null || !isPdEnabled()) { return; } + if (!hugeClient.supportsDefaultRole()) { + throw new ParameterizedException( + "auth.permission-preset.unsupported"); + } List<String> oldadminspaces = listAdminSpace(hugeClient, username); User account = hugeClient.findUserByName(username); E.checkNotNull(account, "User"); - if (!hugeClient.supportsDefaultRole()) { - for (String adminspace : adminspaces) { - if (!oldadminspaces.contains(adminspace)) { - hugeClient.auth().addSpaceAdmin(username, adminspace); - } - } - for (String oldadminspace : oldadminspaces) { - if (!adminspaces.contains(oldadminspace)) { - hugeClient.auth().delSpaceAdmin(username, oldadminspace); - } - } - return; - } for (String adminspace : adminspaces) { if (!oldadminspaces.contains(adminspace)) { this.graphSpaceUserService.applySpacePreset(hugeClient, adminspace, account.id().toString(), 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 class JobManagerService { return this.mapper.selectList(null); } + public List<JobManager> listByGraphSpaces( + Collection<String> graphSpaces) { + if (graphSpaces != null && graphSpaces.isEmpty()) { + return Collections.emptyList(); + } + QueryWrapper<JobManager> query = Wrappers.query(); + if (graphSpaces != null) { + query.in("graphspace", graphSpaces); + } + query.orderByDesc("create_time"); + return this.mapper.selectList(query); + } + + public IPage<JobManager> listByGraphSpaces( + Collection<String> graphSpaces, int pageNo, int pageSize, + String content) { + Page<JobManager> page = new Page<>(pageNo, + PageUtil.boundedSize(pageSize)); + if (graphSpaces != null && graphSpaces.isEmpty()) { + return page; + } + QueryWrapper<JobManager> 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<JobManager> listAll(int pageNo, int pageSize, String content) { QueryWrapper<JobManager> query = Wrappers.query(); if (content != null && !content.isEmpty()) { 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 @@ public class ExecuteHistoryService { } public ExecuteHistory get(HugeClient client, int id) { - ExecuteHistory history = this.mapper.selectById(id); + QueryWrapper<ExecuteHistory> 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 class ExecuteHistoryService { @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<ExecuteHistory> 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 @@ public class GremlinCollectionService { } } - public GremlinCollection get(int id) { - return this.mapper.selectById(id); + public GremlinCollection get(String graphSpace, String graph, int id) { + QueryWrapper<GremlinCollection> 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 class GremlinCollectionService { } @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<GremlinCollection> 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<GremlinCollection> 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 2b5afc2dc..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 @@ -186,9 +186,7 @@ public class GraphSpaceService { space.getNickname().contains(prefix))) .filter(space -> space.getCreateTime() == null || space.getCreateTime().compareTo(after) > 0) - .filter(space -> !space.isAuth() || - client.auth().isSpaceAdmin(space.getName()) || - hasCurrentUserAccess(client, space.getName())) + .filter(space -> canCurrentUserAccess(client, space)) .collect(Collectors.toList()); Collections.sort(results, (a, b) -> new BuiltInFirst().compare( a.getName(), b.getName())); @@ -216,6 +214,14 @@ public class GraphSpaceService { 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<Map<String, Object>> queryAnonymousGs(HugeClient client, String query, String createTime) { @@ -230,13 +236,19 @@ public class GraphSpaceService { .collect(Collectors.toList()); } + public List<String> listAccessible(HugeClient client) { + return queryAccessibleSpaces(client, "", "").stream() + .map(GraphSpace::getName) + .collect(Collectors.toList()); + } + public Map<String, Object> getAnonymous(HugeClient client, String graphSpace) { - return anonymousView(client, publicSpace(client, graphSpace)); + return anonymousView(client, requirePublicSpace(client, graphSpace)); } public boolean isAuthForAnonymous(HugeClient client, String graphSpace) { - return publicSpace(client, graphSpace).isAuth(); + return requirePublicSpace(client, graphSpace).isAuth(); } public IPage<Map<String, Object>> queryAnonymousGsPage( @@ -300,8 +312,17 @@ public class GraphSpaceService { return info; } - private static GraphSpace publicSpace(HugeClient client, - String graphSpace) { + 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); @@ -312,7 +333,7 @@ public class GraphSpaceService { } throw e; } - if (space == null || space.isAuth()) { + if (space == null) { throw unavailableGraphSpace(); } return space; @@ -464,6 +485,11 @@ public class GraphSpaceService { return space.isAuth(); } + public boolean isAuthForAccessible(HugeClient client, + String graphSpace) { + return requireAccessibleSpace(client, graphSpace).isAuth(); + } + public List<String> listAll(HugeClient client) { List<String> result = client.graphSpace().listGraphSpace().stream() .collect(Collectors.toList()); @@ -488,17 +514,34 @@ public class GraphSpaceService { 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/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.JobManager; 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 class IngestControllerTest { 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 class IngestControllerTest { 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<String, Object> option = (Map<String, Object>) + 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 @@ public class IngestControllerTest { 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 7911be002..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 @@ -96,6 +96,7 @@ public class GraphSpaceControllerTest { 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") @@ -152,6 +153,54 @@ public class GraphSpaceControllerTest { .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<String, Object> names = (Map<String, Object>) controller.list(); + @SuppressWarnings("unchecked") + Map<String, Object> detail = + (Map<String, Object>) controller.get("member"); + @SuppressWarnings("unchecked") + Map<String, Object> auth = + (Map<String, Object>) 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(); @@ -211,6 +260,25 @@ public class GraphSpaceControllerTest { 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/service/auth/GraphSpaceUserServiceTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java index 080150e0a..6feee6e3a 100644 --- a/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java +++ b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/service/auth/GraphSpaceUserServiceTest.java @@ -175,13 +175,17 @@ public class GraphSpaceUserServiceTest { } @Test - public void testLegacyAccessDoesNotCallNewDefaultRoleApi() { + 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", "alice")); + this.client, "team", "bob")); Mockito.verifyZeroInteractions(this.graphSpace); - Mockito.verifyZeroInteractions(this.auth); + Mockito.verify(this.auth, Mockito.times(2)).listSpaceMember("team"); } @Test 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 6c051e7ba..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 @@ -152,6 +152,9 @@ public class GraphSpaceServiceTest { .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()); @@ -255,6 +258,34 @@ public class GraphSpaceServiceTest { 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); 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 1f0568c98..34594952d 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 @@ -82,6 +82,7 @@ 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; @@ -210,6 +211,11 @@ public class AuthSecurityTest { 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( @@ -233,6 +239,55 @@ public class AuthSecurityTest { 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(); @@ -371,6 +426,12 @@ public class AuthSecurityTest { 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" + @@ -387,6 +448,7 @@ public class AuthSecurityTest { Assert.assertEquals("space1", interceptor.graphSpace); Assert.assertEquals("graph1", interceptor.graph); Assert.assertEquals("token", interceptor.token); + Mockito.verify(spaces).requireAccessibleSpace(null, "space1"); } @Test @@ -408,6 +470,30 @@ public class AuthSecurityTest { 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 { @@ -429,8 +515,13 @@ public class AuthSecurityTest { 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"); @@ -438,6 +529,38 @@ public class AuthSecurityTest { 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 @@ -868,6 +991,10 @@ public class AuthSecurityTest { public void clearAuth() { this.clearAuthSession(); } + + public void requireSpace(HugeClient client, String graphSpace) { + this.requireGraphSpaceAccess(client, graphSpace); + } } private static class TestLoginController extends LoginController { 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<String, Object> 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.junit.Test; 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 class FileMappingDeletionTest { 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 @@ public class FileMappingDeletionTest { 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.test.web.servlet.setup.MockMvcBuilders; 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 class GraphsControllerCanonicalTest { 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 class GraphsControllerCanonicalTest { 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 @@ public class GraphsControllerCanonicalTest { private HugeClient client; private String graphspace; private String graph; + private String checkedGraphSpace; @Override protected HugeClient authClient(String graphspace, String graph) { @@ -225,5 +269,11 @@ public class GraphsControllerCanonicalTest { 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/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 java.util.Arrays; 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 class LoaderScopeControllerTest { 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 class LoaderScopeControllerTest { .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 class LoaderScopeControllerTest { 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/UserServiceCompatibilityTest.java b/hugegraph-hubble/hubble-be/src/test/java/org/apache/hugegraph/unit/UserServiceCompatibilityTest.java index a3a796181..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 @@ -120,12 +120,15 @@ public class UserServiceCompatibilityTest { .name("user") .nickname("display-name") .build(); + user.setSuperadmin(true); this.service.update(this.client, user); ArgumentCaptor<User> 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 @@ -291,6 +294,33 @@ public class UserServiceCompatibilityTest { .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); @@ -369,6 +399,33 @@ public class UserServiceCompatibilityTest { .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); @@ -411,7 +468,7 @@ public class UserServiceCompatibilityTest { } @Test - public void testLegacyUserUpdateReconcilesAdminSpaces() { + 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()) @@ -420,11 +477,6 @@ public class UserServiceCompatibilityTest { .thenReturn(Collections.singletonList("user")); Mockito.when(this.auth.listSpaceAdmin("NEW")) .thenReturn(Collections.emptyList()); - Mockito.when(this.client.findUserByName("user")) - .thenReturn(user("user")); - Mockito.when(this.auth.listSuperAdmin()) - .thenReturn(Collections.singletonList("user")); - Mockito.when(this.auth.getUser("user")).thenReturn(user("user")); UserEntity account = UserEntity.builder() .id("user") .name("user") @@ -432,15 +484,51 @@ public class UserServiceCompatibilityTest { Collections.singletonList("NEW")) .build(); - this.service.update(this.client, account); + ParameterizedException error = null; + try { + this.service.update(this.client, account); + } catch (ParameterizedException e) { + error = e; + } - Mockito.verify(this.auth).addSpaceAdmin("user", "NEW"); - Mockito.verify(this.auth).delSpaceAdmin("user", "OLD"); - Mockito.verify(this.auth).delSuperAdmin("user"); - Mockito.verify(this.graphSpaceUsers, Mockito.never()) - .applySpacePreset(Mockito.any(), Mockito.anyString(), - Mockito.anyString(), Mockito.anyString(), - Mockito.anyString()); + 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 @@ -485,7 +573,7 @@ public class UserServiceCompatibilityTest { } @Test - public void testLegacyUserCreationSkipsPermissionPresetApis() { + 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"); @@ -494,9 +582,18 @@ public class UserServiceCompatibilityTest { java.util.Collections.singletonMap( "permission_preset", "GS_READ_ONLY"))); - this.service.add(this.client, user); + ParameterizedException error = null; + try { + this.service.add(this.client, user); + } catch (ParameterizedException e) { + error = e; + } - Mockito.verify(this.auth).createUser(Mockito.any(User.class)); + 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()); @@ -587,30 +684,46 @@ public class UserServiceCompatibilityTest { } @Test - public void testLegacyAdminGrantFailureRollsBackEarlierSpaces() { + public void testLegacyAdminGrantIsRejectedBeforeAccountCreation() { Mockito.when(this.config.get(HubbleOptions.PD_ENABLED)).thenReturn(true); Mockito.when(this.client.supportsDefaultRole()).thenReturn(false); - User created = user("user"); - created.setId("u-1"); - Mockito.when(this.auth.createUser(Mockito.any(User.class))) - .thenReturn(created); - RuntimeException failure = new RuntimeException("grant failed"); - Mockito.when(this.auth.addSpaceAdmin("user", "SECOND")) - .thenThrow(failure); UserEntity user = userEntity("display-name"); user.setAdminSpaces(Arrays.asList("FIRST", "SECOND")); - RuntimeException error = null; + ParameterizedException error = null; try { this.service.add(this.client, user); - } catch (RuntimeException e) { + } catch (ParameterizedException e) { error = e; } - Assert.assertSame(failure, error); - Mockito.verify(this.auth).delSpaceAdmin("user", "SECOND"); - Mockito.verify(this.auth).delSpaceAdmin("user", "FIRST"); - Mockito.verify(this.auth).deleteUser("u-1"); + 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 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 febdbdc95..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 @@ -24,6 +24,7 @@ const loadResponseHandlers = modulePath => { const modalWarning = jest.fn(); const clearLogin = jest.fn(); const isLogoutTransition = jest.fn(() => false); + const isAuthEnabled = jest.fn(() => true); const instance = { interceptors: { request: { @@ -59,6 +60,9 @@ const loadResponseHandlers = modulePath => { clearLogin, isLogoutTransition, })); + jest.doMock('../utils/config', () => ({ + isAuthEnabled, + })); const request = require(modulePath).default; return { @@ -68,6 +72,7 @@ const loadResponseHandlers = modulePath => { modalWarning, clearLogin, isLogoutTransition, + isAuthEnabled, instance, request, }; @@ -90,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(); }); @@ -239,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 = { diff --git a/hugegraph-hubble/hubble-fe/src/api/request.js b/hugegraph-hubble/hubble-fe/src/api/request.js index 1b63ae16e..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 => { @@ -152,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) { @@ -184,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 @@ -219,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/i18n/resources/en-US/modules/pages.json b/hugegraph-hubble/hubble-fe/src/i18n/resources/en-US/modules/pages.json index 07838bafd..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 @@ -403,6 +403,7 @@ "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", @@ -1343,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", 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 1efe05188..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 @@ -403,6 +403,7 @@ "save_failed": "账号未保存", "save_retry": "请检查账号字段和 Server 连接后重试。", "delete_retry": "账号删除失败,请刷新列表后重试。", + "password_permission_separate": "请分别保存密码和权限变更。", "presets_unavailable": "GraphSpace 权限预设不可用", "presets_unavailable_help": "当前部署未提供所需权限 API,账号将以无提升权限的普通账号创建", "preset_edit_unavailable": "当前无法编辑权限", @@ -1343,6 +1344,7 @@ "reason_refresh_failed": "刷新失败", "reason_malformed_response": "响应格式异常", "reason_unsupported_version": "当前服务版本不支持", + "reason_unsupported_version_help": "请升级 HugeGraph 到提供该指标的版本。", "reason_deployment_mode_unsupported": "当前部署模式不支持", "metric_labels": { "basic": "内存与进程", diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js index df60813f8..1847ca1fd 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/EditLayer.js @@ -43,6 +43,9 @@ const toProfilePayload = values => ({ 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); @@ -104,7 +107,17 @@ const EditLayer = ({ }); }, [onCancel, permissionPresetsSupported, refresh, t]); const updateUser = useCallback(values => { - const payload = permissionPresetsSupported + 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); @@ -119,7 +132,7 @@ const EditLayer = ({ throw res; }); - }, [onCancel, refresh, data.id, permissionPresetsSupported, t]); + }, [onCancel, refresh, data.id, detail, permissionPresetsSupported, t]); const updateUserAuth = useCallback(values => { const payload = toPermissionPayload({ diff --git a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js index 14c5e20f2..bde43ce88 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Account/account-edit-recovery.test.js @@ -210,6 +210,79 @@ test('preserves mixed GraphSpace permissions on profile edit', async () => { expect(payload).not.toHaveProperty('is_superadmin'); }); +test('updates a password without resubmitting unchanged permissions', async () => { + mockAuthContext = { + capabilities: ['accounts_manage', 'account_permission_presets'], + }; + api.auth.getUserInfo.mockResolvedValue({ + status: 200, + data: { + user_name: 'alice', + permission_preset: 'GS_READ_ONLY', + graphspace_permissions: [{ + graphspace: 'SPACE', + permission_preset: 'GS_READ_ONLY', + }], + }, + }); + api.auth.updateUser.mockResolvedValue({status: 200}); + + render(<EditLayer {...props} data={{id: 'alice'}} op='edit' />); + + await screen.findByDisplayValue('alice'); + const password = screen.getByPlaceholderText( + 'account.form.default_password_placeholder' + ); + fireEvent.change(password, {target: {value: 'new-password'}}); + fireEvent.click(document.querySelector( + '.ant-modal-footer .ant-btn-primary' + )); + + await waitFor(() => expect(api.auth.updateUser).toHaveBeenCalled()); + const payload = api.auth.updateUser.mock.calls[0][1]; + expect(payload.user_password).toBe('new-password'); + expect(payload).not.toHaveProperty('permission_preset'); + expect(payload).not.toHaveProperty('graphspace_permissions'); +}); + +test('rejects a combined password and permission edit before the request', async () => { + mockAuthContext = { + capabilities: ['accounts_manage', 'account_permission_presets'], + }; + api.auth.getUserInfo.mockResolvedValue({ + status: 200, + data: { + user_name: 'alice', + permission_preset: 'GS_READ_ONLY', + graphspace_permissions: [{ + graphspace: 'SPACE', + permission_preset: 'GS_READ_ONLY', + }], + }, + }); + + render(<EditLayer {...props} data={{id: 'alice'}} op='edit' />); + + await screen.findByDisplayValue('alice'); + const password = screen.getByPlaceholderText( + 'account.form.default_password_placeholder' + ); + fireEvent.change(password, {target: {value: 'new-password'}}); + fireEvent.mouseDown(screen.getAllByRole('combobox')[0]); + fireEvent.click(screen.getByText( + 'account.permission_preset.GS_READ_WRITE' + )); + fireEvent.click(document.querySelector( + '.ant-modal-footer .ant-btn-primary' + )); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent( + 'account.feedback.password_permission_separate' + ); + expect(api.auth.updateUser).not.toHaveBeenCalled(); +}); + test('ignores a second account mutation while the first submit is pending', async () => { const detailRequest = deferred(); const mutation = deferred(); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.js index 8269d5699..4d133bc5b 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.js @@ -16,7 +16,7 @@ * under the License. */ -import {Alert, Button, Descriptions, Progress, Skeleton, Space, Statistic} from 'antd'; +import {Alert, Button, Descriptions, Progress, Skeleton, Space, Statistic, Tooltip} from 'antd'; import {ArrowLeftOutlined} from '@ant-design/icons'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useLocation, useNavigate, useParams} from 'react-router-dom'; @@ -319,6 +319,13 @@ const MetricGroup = ({group, name, values, status = {}, emptyMessage}) => { const reasonLabel = status.reason ? t(`operations.reason_${status.reason}`, { defaultValue: status.reason.replaceAll('_', ' '), }) : null; + const upgradeHelp = status.reason === 'unsupported_version' + ? t('operations.reason_unsupported_version_help') : null; + const availabilityStatus = ( + <strong className={`availability-${availability.toLowerCase()}`}> + {availabilityLabel} + </strong> + ); const statusDetails = ( <div className='operations-metric-status' @@ -336,9 +343,11 @@ const MetricGroup = ({group, name, values, status = {}, emptyMessage}) => { <header className='operations-metric-header'> <div className='operations-metric-title-row'> <h3>{name}</h3> - <strong className={`availability-${availability.toLowerCase()}`}> - {availabilityLabel} - </strong> + {upgradeHelp ? ( + <Tooltip title={upgradeHelp}> + <span tabIndex={0}>{availabilityStatus}</span> + </Tooltip> + ) : availabilityStatus} </div> {statusDetails} </header> diff --git a/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.test.js b/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.test.js index f3796c8eb..2d6cbc506 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Operations/NodeDetail.test.js @@ -340,6 +340,10 @@ test('renders each metric group from its own metric status', async () => { expect(within(raft).getByText('Unsupported service version')).toBeInTheDocument(); expect(within(raft).getByText(/Unsupported by this service version/)) .toBeInTheDocument(); + fireEvent.mouseOver(within(raft).getByText('Unsupported')); + expect(await screen.findByText( + 'Upgrade HugeGraph to a version that provides this metric.' + )).toBeInTheDocument(); const backend = screen.getByRole('heading', {name: 'Backend'}).closest('section'); expect(within(backend).getByText('Available')).toBeInTheDocument();
