diff --git a/caosdb-proto b/caosdb-proto index c6405e538c179d2a8af952f85d9e9dc51fbadb92..79549b60e441c0b2d693ce189d6e189fed331a7c 160000 --- a/caosdb-proto +++ b/caosdb-proto @@ -1 +1 @@ -Subproject commit c6405e538c179d2a8af952f85d9e9dc51fbadb92 +Subproject commit 79549b60e441c0b2d693ce189d6e189fed331a7c diff --git a/src/main/java/org/caosdb/server/grpc/GRPCServer.java b/src/main/java/org/caosdb/server/grpc/GRPCServer.java index 494ce94706eff75415264b175f58cdaf16a2a442..ee2bb036d0911136834d230b8994ef841aca8e19 100644 --- a/src/main/java/org/caosdb/server/grpc/GRPCServer.java +++ b/src/main/java/org/caosdb/server/grpc/GRPCServer.java @@ -141,6 +141,12 @@ public class GRPCServer { ServerInterceptors.intercept( entityTransactionService, loggingInterceptor, authInterceptor)); + final ServerSideScriptingServiceImpl serverSideScriptingService = + new ServerSideScriptingServiceImpl(); + services.add( + ServerInterceptors.intercept( + serverSideScriptingService, loggingInterceptor, authInterceptor)); + return services; } diff --git a/src/main/java/org/caosdb/server/grpc/ServerSideScriptingServiceImpl.java b/src/main/java/org/caosdb/server/grpc/ServerSideScriptingServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..99ed6a2970dab9369927b169fa7fe8018a34080f --- /dev/null +++ b/src/main/java/org/caosdb/server/grpc/ServerSideScriptingServiceImpl.java @@ -0,0 +1,284 @@ +/* + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2025 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2025 Joscha Schmiedt <joscha@schmiedt.dev> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package org.caosdb.server.grpc; + +import com.google.protobuf.Timestamp; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.stub.StreamObserver; +import java.io.IOException; +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.authz.UnauthorizedException; +import org.apache.shiro.subject.Subject; +import org.caosdb.api.scripting.v1alpha1.ExecuteServerSideScriptRequest; +import org.caosdb.api.scripting.v1alpha1.ExecuteServerSideScriptResponse; +import org.caosdb.api.scripting.v1alpha1.NamedArgument; +import org.caosdb.api.scripting.v1alpha1.ServerSideScriptExecutionId; +import org.caosdb.api.scripting.v1alpha1.ServerSideScriptExecutionResult; +import org.caosdb.api.scripting.v1alpha1.ServerSideScriptingServiceGrpc.ServerSideScriptingServiceImplBase; +import org.caosdb.server.accessControl.AuthenticationUtils; +import org.caosdb.server.accessControl.OneTimeAuthenticationToken; +import org.caosdb.server.accessControl.SessionToken; +import org.caosdb.server.entity.FileProperties; +import org.caosdb.server.entity.Message; +import org.caosdb.server.scripting.ScriptingPermissions; +import org.caosdb.server.scripting.ServerSideScriptingCaller; +import org.caosdb.server.utils.ServerMessages; + +/** + * Main entry point for the server-side scripting service of the server's GRPC API. + * + * @author Joscha Schmiedt <joscha@schmiedt.dev> + */ +public class ServerSideScriptingServiceImpl extends ServerSideScriptingServiceImplBase { + + private ServerSideScriptingCaller caller; + private static AtomicLong executionId = new AtomicLong(0); + + @Override + public void executeServerSideScript( + ExecuteServerSideScriptRequest request, + io.grpc.stub.StreamObserver<org.caosdb.api.scripting.v1alpha1.ExecuteServerSideScriptResponse> + responseObserver) { + + try { + AuthInterceptor.bindSubject(); + ExecuteServerSideScriptResponse response = callScript(request); + responseObserver.onNext(response); + responseObserver.onCompleted(); + + } catch (final Exception e) { + ServerSideScriptingServiceImpl.handleException(responseObserver, e); + } + } + + private long incrementAndGetExecutionId() { + return executionId.incrementAndGet(); + } + + /** + * Converts the ExecuteServerSideScriptRequest to a command line argument list. + * + * @param request the request containing the script filename and arguments + * @return the command line argument list + * @throws IllegalArgumentException if the script filename is missing + */ + public static ArrayList<String> request2CommandLine(ExecuteServerSideScriptRequest request) + throws IllegalArgumentException { + + if (request.getScriptFilename().length() == 0) { + throw new IllegalArgumentException("Script filename is missing in the request."); + } + + ArrayList<String> commandLine = + new ArrayList<>( + 1 + request.getPositionalArgumentsCount() + request.getNamedArgumentsCount()); + + // first item is the script filename + commandLine.add(request.getScriptFilename()); + + // add named arguments + for (NamedArgument namedArgument : request.getNamedArgumentsList()) { + String name = namedArgument.getName(); + String value = namedArgument.getValue(); + commandLine.add(String.format("--%s=%s", name, value)); + } + + // add positional arguments + for (String positionalArgument : request.getPositionalArgumentsList()) { + commandLine.add(positionalArgument); + } + + return commandLine; + } + + ///////////////////////////////////////////////////////////////////////////////////////// + // TODO: isAnonymous, getUser and generateAuthToken are copy-pasted from + // elsewhere. Consider refactoring. + ///////////////////////////////////////////////////////////////////////////////////////// + private static boolean isAnonymous() { + return AuthenticationUtils.isAnonymous(getUser()); + } + + private static Subject getUser() { + Subject ret = SecurityUtils.getSubject(); + return ret; + } + + private static String[] constructCallStringArray(ArrayList<String> commandLine) { + return commandLine.toArray(new String[commandLine.size()]); + } + + private static Object generateAuthToken(String call) { + String purpose = "SCRIPTING:EXECUTE:" + call; + Object authtoken = OneTimeAuthenticationToken.generateForPurpose(purpose, getUser()); + if (authtoken != null || isAnonymous()) { + return authtoken; + } + return SessionToken.generate(getUser()); + } + + private static void checkExecutionPermission(Subject user, String call) + throws AuthorizationException { + user.checkPermission(ScriptingPermissions.PERMISSION_EXECUTION(call)); + } + + ///////////////////////////////////////////////////////////////////////////////////////// + + public ExecuteServerSideScriptResponse callScript(ExecuteServerSideScriptRequest request) + throws Message { + + long startTimeMillis = System.currentTimeMillis(); + Timestamp startTime = Timestamp.newBuilder().setSeconds(startTimeMillis / 1000).build(); + + checkExecutionPermission(getUser(), request.getScriptFilename()); + + String[] call = constructCallStringArray(request2CommandLine(request)); + + Integer timeoutMs = request.getTimeoutMs() != 0 ? (int) request.getTimeoutMs() : -1; + + if (request.getScriptFilesCount() > 0) { + throw new UnsupportedOperationException( + "Uploading files for scripting via gRPC is not supported yet."); + } + + // auth token + Object authToken = request.getAuthToken(); + if (authToken == null) { + authToken = generateAuthToken(request.getScriptFilename()); + } + + // invoke script + caller = + new ServerSideScriptingCaller(call, timeoutMs, new ArrayList<FileProperties>(), authToken); + int returnCode = caller.invoke(); + + // construct response + String scriptExecutionId = String.valueOf(incrementAndGetExecutionId()); + String scriptFilename = request.getScriptFilename(); + ServerSideScriptExecutionResult result = + ServerSideScriptExecutionResult.SERVER_SIDE_SCRIPT_EXECUTION_RESULT_SUCCESS; + + String stdout = ""; + try { + stdout = caller.getStdOut(); + } catch (final IOException e) { + stdout = "Error retreiving stdout"; + result = ServerSideScriptExecutionResult.SERVER_SIDE_SCRIPT_EXECUTION_RESULT_GENERAL_FAILURE; + } + + String stderr = ""; + try { + stderr = caller.getStdErr(); + } catch (final IOException e) { + stderr = "Error retrieving stderr"; + result = ServerSideScriptExecutionResult.SERVER_SIDE_SCRIPT_EXECUTION_RESULT_GENERAL_FAILURE; + } + + long endTimeMillis = System.currentTimeMillis(); + Timestamp endTime = Timestamp.newBuilder().setSeconds(endTimeMillis / 1000).build(); + int duration_ms = (int) (endTimeMillis - startTimeMillis); + + final ExecuteServerSideScriptResponse response = + ExecuteServerSideScriptResponse.newBuilder() + .setScriptExecutionId( + ServerSideScriptExecutionId.newBuilder() + .setScriptExecutionId(scriptExecutionId) + .build()) + .setCall(scriptFilename) + .setResult(result) + .setReturnCode(returnCode) + .setStdout(stdout) + .setStderr(stderr) + .setStartTime(startTime) + .setEndTime(endTime) + .setDurationMs(duration_ms) + .build(); + return response; + } + + public static void handleException(StreamObserver<?> responseObserver, Exception e) { + String description = e.getMessage(); + if (description == null || description.isBlank()) { + description = "Unknown Error. Please Report!"; + } + if (e instanceof UnauthorizedException) { + Subject subject = SecurityUtils.getSubject(); + if (AuthenticationUtils.isAnonymous(subject)) { + responseObserver.onError(new StatusException(AuthInterceptor.PLEASE_LOG_IN)); + return; + } else { + responseObserver.onError( + new StatusException( + Status.PERMISSION_DENIED.withCause(e).withDescription(description))); + return; + } + } else if (e == ServerMessages.ROLE_DOES_NOT_EXIST + || e == ServerMessages.ACCOUNT_DOES_NOT_EXIST) { + responseObserver.onError( + new StatusException(Status.NOT_FOUND.withDescription(description).withCause(e))); + return; + } + else if (e == ServerMessages.SERVER_SIDE_SCRIPT_DOES_NOT_EXIST) + { + responseObserver.onError( + new StatusException(Status.NOT_FOUND.withDescription(description).withCause(e))); + return; + } + else if (e == ServerMessages.SERVER_SIDE_SCRIPT_NOT_EXECUTABLE) + { + responseObserver.onError( + new StatusException(Status.PERMISSION_DENIED.withDescription(description).withCause(e))); + return; + } + else if (e == ServerMessages.SERVER_SIDE_SCRIPT_ERROR) + { + responseObserver.onError( + new StatusException(Status.UNKNOWN.withDescription(description).withCause(e))); + return; + } + else if (e == ServerMessages.SERVER_SIDE_SCRIPT_SETUP_ERROR) + { + responseObserver.onError( + new StatusException(Status.UNKNOWN.withDescription(description).withCause(e))); + return; + } + else if (e == ServerMessages.SERVER_SIDE_SCRIPT_TIMEOUT) + { + responseObserver.onError( + new StatusException(Status.DEADLINE_EXCEEDED.withDescription(description).withCause(e))); + return; + } + else if (e == ServerMessages.SERVER_SIDE_SCRIPT_MISSING_CALL) + { + responseObserver.onError( + new StatusException(Status.INVALID_ARGUMENT.withDescription(description).withCause(e))); + return; + } + + e.printStackTrace(); + responseObserver.onError( + new StatusException(Status.UNKNOWN.withDescription(description).withCause(e))); + } +} diff --git a/src/test/java/org/caosdb/server/grpc/ServerSideScriptingGrpcTest.java b/src/test/java/org/caosdb/server/grpc/ServerSideScriptingGrpcTest.java new file mode 100644 index 0000000000000000000000000000000000000000..013b239771ee8d85a9854398a99467c6509f6738 --- /dev/null +++ b/src/test/java/org/caosdb/server/grpc/ServerSideScriptingGrpcTest.java @@ -0,0 +1,55 @@ +/* + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2025 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2025 Joscha Schmiedt <joscha@schmiedt.dev> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package org.caosdb.server.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import org.caosdb.api.scripting.v1alpha1.ExecuteServerSideScriptRequest; +import org.caosdb.api.scripting.v1alpha1.NamedArgument; +import org.caosdb.server.entity.Message; +import org.junit.jupiter.api.Test; + +public class ServerSideScriptingGrpcTest { + + @Test + public void testExecuteServerSideScriptRequestToCommandline() throws Message { + ExecuteServerSideScriptRequest request = + ExecuteServerSideScriptRequest.newBuilder() + .setScriptFilename("my_script.py") + .addPositionalArguments("pos0") + .addPositionalArguments("pos1") + .addNamedArguments( + NamedArgument.newBuilder().setName("option1").setValue("val1").build()) + .addNamedArguments( + NamedArgument.newBuilder().setName("option2").setValue("val2").build()) + // .putFiles("-Ofile", "test_file.txt") + .build(); + + ArrayList<String> commandline = ServerSideScriptingServiceImpl.request2CommandLine(request); + + assertEquals(commandline.get(0), "my_script.py"); + assertEquals(commandline.get(1), "--option1=val1"); + assertEquals(commandline.get(2), "--option2=val2"); + assertEquals(commandline.get(3), "pos0"); + assertEquals(commandline.get(4), "pos1"); + } +}