diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java index 239ef3674d44..4f00e1bb117f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java @@ -21,4 +21,8 @@ public enum AuthenticationMechanism { * The user used the artemis client code editor to authenticate to the LocalVC */ CODE_EDITOR, + /** + * The user attempted to authenticate to the LocalVC using either a user token or a participation token + */ + VCS_ACCESS_TOKEN, } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java index 560ca52a31c1..9d5155d63f14 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/VcsAccessLog.java @@ -77,6 +77,10 @@ public void setCommitHash(String commitHash) { this.commitHash = commitHash; } + public void setRepositoryActionType(RepositoryActionType repositoryActionType) { + this.repositoryActionType = repositoryActionType; + } + public User getUser() { return user; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java index 3739ed8dff71..9ba656a1ec00 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java @@ -82,6 +82,12 @@ default ProgrammingExerciseStudentParticipation findByExerciseIdAndStudentLoginO return getValueElseThrow(findByExerciseIdAndStudentLogin(exerciseId, username)); } + Optional findByRepositoryUri(@Param("repositoryUri") String repositoryUri); + + default ProgrammingExerciseStudentParticipation findByRepositoryUriElseThrow(String repositoryUri) { + return getValueElseThrow(findByRepositoryUri(repositoryUri)); + } + @EntityGraph(type = LOAD, attributePaths = { "submissions" }) Optional findWithSubmissionsByExerciseIdAndStudentLogin(long exerciseId, String username); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/SolutionProgrammingExerciseParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/SolutionProgrammingExerciseParticipationRepository.java index 2949576a13cb..852c9f920c63 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/SolutionProgrammingExerciseParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/SolutionProgrammingExerciseParticipationRepository.java @@ -43,6 +43,12 @@ public interface SolutionProgrammingExerciseParticipationRepository """) Optional findByBuildPlanIdWithResults(@Param("buildPlanId") String buildPlanId); + Optional findByRepositoryUri(@Param("repositoryUri") String repositoryUri); + + default SolutionProgrammingExerciseParticipation findByRepositoryUriElseThrow(String repositoryUri) { + return getValueElseThrow(findByRepositoryUri(repositoryUri)); + } + @EntityGraph(type = LOAD, attributePaths = { "results", "submissions", "submissions.results" }) Optional findWithEagerResultsAndSubmissionsByProgrammingExerciseId(long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/TemplateProgrammingExerciseParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/TemplateProgrammingExerciseParticipationRepository.java index bc609dcd06fa..f709131869e0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/TemplateProgrammingExerciseParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/TemplateProgrammingExerciseParticipationRepository.java @@ -48,6 +48,12 @@ default TemplateProgrammingExerciseParticipation findWithEagerResultsAndSubmissi return getValueElseThrow(findWithEagerResultsAndSubmissionsByProgrammingExerciseId(exerciseId)); } + Optional findByRepositoryUri(@Param("repositoryUri") String repositoryUri); + + default TemplateProgrammingExerciseParticipation findByRepositoryUriElseThrow(String repositoryUri) { + return getValueElseThrow(findByRepositoryUri(repositoryUri)); + } + @EntityGraph(type = LOAD, attributePaths = { "results", "results.feedbacks", "results.feedbacks.testCase", "submissions" }) Optional findWithEagerResultsAndFeedbacksAndTestCasesAndSubmissionsByProgrammingExerciseId(long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java index af342179e111..628019f34eaa 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/VcsAccessLogRepository.java @@ -33,14 +33,14 @@ public interface VcsAccessLogRepository extends ArtemisJpaRepository findNewestByParticipationIdWhereCommitHashIsNull(@Param("participationId") long participationId); + FROM VcsAccessLog vcsAccessLog + WHERE vcsAccessLog.participation.id = :participationId + ORDER BY vcsAccessLog.timestamp DESC + LIMIT 1 + """) + Optional findNewestByParticipationId(@Param("participationId") long participationId); /** * Retrieves a list of {@link VcsAccessLog} entities associated with the specified participation ID. @@ -62,7 +62,6 @@ public interface VcsAccessLogRepository extends ArtemisJpaRepository + * For general information on the different hooks and git packs see the git documentation: + *

+ * https://git-scm.com/docs/git-receive-pack + *

+ * https://git-scm.com/docs/git-upload-pack */ @PostConstruct @Override @@ -55,5 +63,13 @@ public void init() { receivePack.setPostReceiveHook(new LocalVCPostPushHook(localVCServletService)); return receivePack; }); + + this.setUploadPackFactory((request, repository) -> { + UploadPack uploadPack = new UploadPack(repository); + + // Add the custom pre-upload hook, to distinguish between clone and pull operations + uploadPack.setPreUploadHook(new LocalVCFetchPreUploadHook(localVCServletService, request)); + return uploadPack; + }); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchFilter.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchFilter.java index 504355f1cdf2..e789bf4c5e78 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchFilter.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchFilter.java @@ -10,6 +10,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.filter.OncePerRequestFilter; import de.tum.cit.aet.artemis.core.exception.localvc.LocalVCAuthException; @@ -44,6 +45,11 @@ public void doFilterInternal(HttpServletRequest servletRequest, HttpServletRespo servletResponse.setStatus(localVCServletService.getHttpStatusForException(e, servletRequest.getRequestURI())); return; } + catch (AuthenticationException e) { + // intercept failed authentication to log it in the VCS access log + localVCServletService.createVCSAccessLogForFailedAuthenticationAttempt(servletRequest); + throw e; + } filterChain.doFilter(servletRequest, servletResponse); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHook.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHook.java new file mode 100644 index 000000000000..97e7523991d7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHook.java @@ -0,0 +1,34 @@ +package de.tum.cit.aet.artemis.programming.service.localvc; + +import java.util.Collection; + +import jakarta.servlet.http.HttpServletRequest; + +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.transport.PreUploadHook; +import org.eclipse.jgit.transport.UploadPack; + +public class LocalVCFetchPreUploadHook implements PreUploadHook { + + private final LocalVCServletService localVCServletService; + + private final HttpServletRequest request; + + public LocalVCFetchPreUploadHook(LocalVCServletService localVCServletService, HttpServletRequest request) { + this.localVCServletService = localVCServletService; + this.request = request; + } + + @Override + public void onBeginNegotiateRound(UploadPack uploadPack, Collection collection, int clientOffered) { + localVCServletService.updateVCSAccessLogForCloneAndPullHTTPS(request, clientOffered); + } + + @Override + public void onEndNegotiateRound(UploadPack uploadPack, Collection collection, int i, int i1, boolean b) { + } + + @Override + public void onSendPack(UploadPack uploadPack, Collection collection, Collection collection1) { + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHookSSH.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHookSSH.java new file mode 100644 index 000000000000..09f79348c180 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCFetchPreUploadHookSSH.java @@ -0,0 +1,37 @@ +package de.tum.cit.aet.artemis.programming.service.localvc; + +import java.nio.file.Path; +import java.util.Collection; + +import org.apache.sshd.server.session.ServerSession; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.transport.PreUploadHook; +import org.eclipse.jgit.transport.UploadPack; + +public class LocalVCFetchPreUploadHookSSH implements PreUploadHook { + + private final LocalVCServletService localVCServletService; + + private final ServerSession serverSession; + + private final Path rootDir; + + public LocalVCFetchPreUploadHookSSH(LocalVCServletService localVCServletService, ServerSession serverSession, Path rootDir) { + this.localVCServletService = localVCServletService; + this.serverSession = serverSession; + this.rootDir = rootDir; + } + + @Override + public void onBeginNegotiateRound(UploadPack uploadPack, Collection collection, int clientOffered) { + localVCServletService.updateVCSAccessLogForCloneAndPullSSH(serverSession, rootDir, clientOffered); + } + + @Override + public void onEndNegotiateRound(UploadPack uploadPack, Collection collection, int i, int i1, boolean b) { + } + + @Override + public void onSendPack(UploadPack uploadPack, Collection collection, Collection collection1) { + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCPushFilter.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCPushFilter.java index 3d178b998cdd..914205081e60 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCPushFilter.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCPushFilter.java @@ -47,6 +47,7 @@ public void doFilterInternal(HttpServletRequest servletRequest, HttpServletRespo servletResponse.setStatus(localVCServletService.getHttpStatusForException(e, servletRequest.getRequestURI())); return; } + this.localVCServletService.updateVCSAccessLogForPushHTTPS(servletRequest); filterChain.doFilter(servletRequest, servletResponse); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java index 470b7f815322..f4bcf1ea2e89 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCServletService.java @@ -16,10 +16,12 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.lang3.StringUtils; +import org.apache.sshd.server.session.ServerSession; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.errors.RepositoryNotFoundException; @@ -32,6 +34,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Async; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.AuthenticationException; @@ -66,6 +69,7 @@ import de.tum.cit.aet.artemis.programming.service.ProgrammingTriggerService; import de.tum.cit.aet.artemis.programming.service.RepositoryAccessService; import de.tum.cit.aet.artemis.programming.service.ci.ContinuousIntegrationTriggerService; +import de.tum.cit.aet.artemis.programming.service.localvc.ssh.SshConstants; import de.tum.cit.aet.artemis.programming.web.repository.RepositoryActionType; /** @@ -127,6 +131,8 @@ public void setLocalVCBaseUrl(URL localVCBaseUrl) { */ public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BUILD_USER_NAME = "buildjob_user"; + // Cache the retrieved repositories for quicker access. // The resolveRepository method is called multiple times per request. // Key: repositoryPath --> Value: Repository @@ -207,7 +213,8 @@ public Repository resolveRepository(String repositoryPath) throws RepositoryNotF * @throws LocalVCForbiddenException If the user is not allowed to access the repository, e.g. because offline IDE usage is not allowed or the due date has passed. * @throws LocalVCInternalException If an internal error occurs, e.g. because the LocalVCRepositoryUri could not be created. */ - public void authenticateAndAuthorizeGitRequest(HttpServletRequest request, RepositoryActionType repositoryAction) throws LocalVCAuthException, LocalVCForbiddenException { + public void authenticateAndAuthorizeGitRequest(HttpServletRequest request, RepositoryActionType repositoryAction) + throws LocalVCAuthException, LocalVCForbiddenException, AuthenticationException { long timeNanoStart = System.nanoTime(); @@ -274,7 +281,8 @@ private AuthenticationMechanism resolveAuthenticationMechanism(String authorizat return AuthenticationMechanism.PARTICIPATION_VCS_ACCESS_TOKEN; } - private User authenticateUser(String authorizationHeader, ProgrammingExercise exercise, LocalVCRepositoryUri localVCRepositoryUri) throws LocalVCAuthException { + private User authenticateUser(String authorizationHeader, ProgrammingExercise exercise, LocalVCRepositoryUri localVCRepositoryUri) + throws LocalVCAuthException, AuthenticationException { UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); @@ -428,8 +436,7 @@ public void authorizeUser(String repositoryTypeOrUserName, User user, Programmin ProgrammingExerciseParticipation participation; try { - participation = programmingExerciseParticipationService.getParticipationForRepository(exercise, repositoryTypeOrUserName, localVCRepositoryUri.isPracticeRepository(), - false); + participation = programmingExerciseParticipationService.retrieveParticipationForRepository(repositoryTypeOrUserName, localVCRepositoryUri.toString()); } catch (EntityNotFoundException e) { throw new LocalVCInternalException( @@ -442,20 +449,40 @@ public void authorizeUser(String repositoryTypeOrUserName, User user, Programmin catch (AccessForbiddenException e) { throw new LocalVCForbiddenException(e); } - // TODO: retrieving the git commit hash should be done ASYNC together with storing the log in the database to avoid long waiting times during permission check - String commitHash = null; + + // Asynchronously store an VCS access log entry + CompletableFuture.runAsync(() -> storeAccessLogAsync(user, participation, repositoryActionType, authenticationMechanism, ipAddress, localVCRepositoryUri)) + .exceptionally(ex -> { + log.warn("Failed to asynchronously obtain commit hash or store access log for repository {}. Error: {}", localVCRepositoryUri.getRelativeRepositoryPath(), + ex.getMessage()); + return null; + }); + } + + /** + * Asynchronously retrieves the latest commit hash from the specified repository and logs the access to the repository. + * This method runs without blocking the user during repository access checks. + * + * @param user the user accessing the repository + * @param participation the participation associated with the repository + * @param repositoryActionType the action performed on the repository (READ or WRITE) + * @param authenticationMechanism the mechanism used for authentication (e.g., token, basic auth) + * @param ipAddress the IP address of the user accessing the repository + * @param localVCRepositoryUri the URI of the localVC repository + */ + private void storeAccessLogAsync(User user, ProgrammingExerciseParticipation participation, RepositoryActionType repositoryActionType, + AuthenticationMechanism authenticationMechanism, String ipAddress, LocalVCRepositoryUri localVCRepositoryUri) { try { - if (repositoryActionType == RepositoryActionType.READ) { - String relativeRepositoryPath = localVCRepositoryUri.getRelativeRepositoryPath().toString(); - try (Repository repository = resolveRepository(relativeRepositoryPath)) { - commitHash = getLatestCommitHash(repository); - } + String commitHash; + String relativeRepositoryPath = localVCRepositoryUri.getRelativeRepositoryPath().toString(); + try (Repository repository = resolveRepository(relativeRepositoryPath)) { + commitHash = getLatestCommitHash(repository); } - // Write a access log entry to the database - String finalCommitHash = commitHash; - vcsAccessLogService.ifPresent(service -> service.storeAccessLog(user, participation, repositoryActionType, authenticationMechanism, finalCommitHash, ipAddress)); + + RepositoryActionType finalRepositoryActionType = repositoryActionType == RepositoryActionType.READ ? RepositoryActionType.PULL : RepositoryActionType.PUSH; + vcsAccessLogService.ifPresent(service -> service.storeAccessLog(user, participation, finalRepositoryActionType, authenticationMechanism, commitHash, ipAddress)); + } - // NOTE: we intentionally catch all issues here to avoid that the user is blocked from accessing the repository catch (Exception e) { log.warn("Failed to obtain commit hash or store access log for repository {}. Error: {}", localVCRepositoryUri.getRelativeRepositoryPath().toString(), e.getMessage()); } @@ -526,16 +553,9 @@ public void processNewPush(String commitHash, Repository repository) { // Process push to any repository other than the test repository. processNewPushToRepository(participation, commit); - try { - // For push the correct commitHash is only available here, therefore the preliminary null value is overwritten - String finalCommitHash = commitHash; - vcsAccessLogService.ifPresent(service -> service.updateCommitHash(participation, finalCommitHash)); - } - // NOTE: we intentionally catch all issues here to avoid that the user is blocked from accessing the repository - catch (Exception e) { - log.warn("Failed to obtain commit hash or store access log for repository {}. Error: {}", localVCRepositoryUri.getRelativeRepositoryPath().toString(), - e.getMessage()); - } + // For push the correct commitHash is only available here, therefore the preliminary null value is overwritten + String finalCommitHash = commitHash; + vcsAccessLogService.ifPresent(service -> service.updateCommitHash(participation, finalCommitHash)); } catch (GitAPIException | IOException e) { // This catch clause does not catch exceptions that happen during runBuildJob() as that method is called asynchronously. @@ -552,8 +572,8 @@ private ProgrammingExerciseParticipation getProgrammingExerciseParticipation(Loc ProgrammingExercise exercise) { ProgrammingExerciseParticipation participation; try { - participation = programmingExerciseParticipationService.getParticipationForRepository(exercise, repositoryTypeOrUserName, localVCRepositoryUri.isPracticeRepository(), - true); + participation = programmingExerciseParticipationService.retrieveParticipationForRepository(exercise, repositoryTypeOrUserName, + localVCRepositoryUri.isPracticeRepository(), true); } catch (EntityNotFoundException e) { throw new VersionControlException("Could not find participation for repository " + repositoryTypeOrUserName + " of exercise " + exercise, e); @@ -704,6 +724,29 @@ private Commit extractCommitInfo(String commitHash, Repository repository) throw return new Commit(commitHash, author.getName(), revCommit.getFullMessage(), author.getEmailAddress(), branch); } + /** + * Retrieves the participation for a programming exercise based on the repository URI. + * + * @param localVCRepositoryUri the {@link LocalVCRepositoryUri} containing details about the repository. + * @return the {@link ProgrammingExerciseParticipation} corresponding to the repository URI. + */ + private ProgrammingExerciseParticipation retrieveParticipationFromLocalVCRepositoryUri(LocalVCRepositoryUri localVCRepositoryUri) { + String repositoryTypeOrUserName = localVCRepositoryUri.getRepositoryTypeOrUserName(); + var repositoryURL = localVCRepositoryUri.toString().replace("/git-upload-pack", "").replace("/git-receive-pack", ""); + return programmingExerciseParticipationService.retrieveParticipationForRepository(repositoryTypeOrUserName, repositoryURL); + } + + /** + * Retrieves the participation for a programming exercise based on the HTTP request. + * + * @param request the {@link HttpServletRequest} containing the repository URI. + * @return the {@link ProgrammingExerciseParticipation} corresponding to the repository details in the request. + */ + private ProgrammingExerciseParticipation getExerciseParticipationFromRequest(HttpServletRequest request) { + LocalVCRepositoryUri localVCRepositoryUri = parseRepositoryUri(request); + return retrieveParticipationFromLocalVCRepositoryUri(localVCRepositoryUri); + } + /** * Determine the default branch of the given repository. * @@ -715,6 +758,127 @@ public static String getDefaultBranchOfRepository(Repository repository) { return LocalVCService.getDefaultBranchOfRepository(repositoryFolderPath.toString()); } + /** + * Updates the VCS (Version Control System) access log for clone and pull actions using HTTPS. + *

+ * This method logs the access information based on the incoming HTTP request. It checks if the action + * is performed by a build job user and, if not, records the user's repository action (clone or pull). + * The action type is determined based on the number of offers (`clientOffered`). + * + * @param request the {@link HttpServletRequest} containing the HTTP request data, including headers. + * @param clientOffered the number of objects offered by the client in the operation, used to determine + * if the action is a clone (if 0) or a pull (if greater than 0). + */ + @Async + public void updateVCSAccessLogForCloneAndPullHTTPS(HttpServletRequest request, int clientOffered) { + try { + String authorizationHeader = request.getHeader(LocalVCServletService.AUTHORIZATION_HEADER); + UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); + String userName = usernameAndPassword.username(); + if (userName.equals(BUILD_USER_NAME)) { + return; + } + RepositoryActionType repositoryActionType = getRepositoryActionReadType(clientOffered); + var participation = getExerciseParticipationFromRequest(request); + + vcsAccessLogService.ifPresent(service -> service.updateRepositoryActionType(participation, repositoryActionType)); + } + catch (Exception ignored) { + } + } + + /** + * Updates the VCS access log for a push action using HTTPS. + *

+ * This method logs the access information if the HTTP request is a POST request and the action + * is not performed by a build job user. The repository action type is set as a push action. + * + * This method is asynchronous. + * + * @param request the {@link HttpServletRequest} containing the HTTP request data, including headers. + */ + @Async + public void updateVCSAccessLogForPushHTTPS(HttpServletRequest request) { + if (!request.getMethod().equals("POST")) { + return; + } + try { + String authorizationHeader = request.getHeader(LocalVCServletService.AUTHORIZATION_HEADER); + UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); + String userName = usernameAndPassword.username(); + if (userName.equals(BUILD_USER_NAME)) { + return; + } + RepositoryActionType repositoryActionType = RepositoryActionType.PUSH; + var participation = getExerciseParticipationFromRequest(request); + + vcsAccessLogService.ifPresent(service -> service.updateRepositoryActionType(participation, repositoryActionType)); + } + catch (Exception ignored) { + } + } + + /** + * Updates the VCS access log for clone and pull actions performed over SSH. + *

+ * This method logs access information based on the SSH session and the root directory of the repository. + * It determines the repository action (clone or pull) based on the number of offers (`clientOffered`) and + * fetches participation details from the local VC repository URI. + * + * @param session the {@link ServerSession} representing the SSH session. + * @param rootDir the {@link Path} to the root directory of the repository. + * @param clientOffered the number of objects offered by the client in the operation, used to determine + * if the action is a clone (if 0) or a pull (if greater than 0). + */ + @Async + public void updateVCSAccessLogForCloneAndPullSSH(ServerSession session, Path rootDir, int clientOffered) { + try { + if (session.getAttribute(SshConstants.USER_KEY).getName().equals(BUILD_USER_NAME)) { + return; + } + RepositoryActionType repositoryActionType = getRepositoryActionReadType(clientOffered); + var participation = retrieveParticipationFromLocalVCRepositoryUri(getLocalVCRepositoryUri(rootDir)); + vcsAccessLogService.ifPresent(service -> service.updateRepositoryActionType(participation, repositoryActionType)); + } + catch (Exception ignored) { + } + } + + /** + * Adds a failed VCS access attempt to the log. + *

+ * This method logs a failed clone attempt, associating it with the user and participation retrieved + * from the incoming HTTP request. It assumes that the failed attempt used password authentication. + * + * @param servletRequest the {@link HttpServletRequest} containing the HTTP request data. + */ + public void createVCSAccessLogForFailedAuthenticationAttempt(HttpServletRequest servletRequest) { + try { + String authorizationHeader = servletRequest.getHeader(LocalVCServletService.AUTHORIZATION_HEADER); + UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); + User user = userRepository.findOneByLogin(usernameAndPassword.username()).orElseThrow(LocalVCAuthException::new); + AuthenticationMechanism mechanism = usernameAndPassword.password().startsWith("vcpat-") ? AuthenticationMechanism.VCS_ACCESS_TOKEN : AuthenticationMechanism.PASSWORD; + var participation = getExerciseParticipationFromRequest(servletRequest); + var ipAddress = servletRequest.getRemoteAddr(); + vcsAccessLogService.ifPresent(service -> service.storeAccessLog(user, participation, RepositoryActionType.CLONE_FAIL, mechanism, "", ipAddress)); + } + catch (LocalVCAuthException ignored) { + } + } + + /** + * Determines the repository action type for read operations (clone or pull). + *

+ * This method returns a {@link RepositoryActionType} based on the number of objects offered. + * If no objects are offered (0), it is considered a clone; otherwise, it is a pull action. + * + * @param clientOffered the number of objects offered to the client in the operation. + * @return the {@link RepositoryActionType} based on the number of objects offered (clone if 0, pull if greater than 0). + */ + private RepositoryActionType getRepositoryActionReadType(int clientOffered) { + return clientOffered == 0 ? RepositoryActionType.CLONE : RepositoryActionType.PULL; + } + record UsernameAndPassword(String username, String password) { } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java index a61712685ef7..d45bfda1c179 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/SshGitLocationResolverService.java @@ -70,12 +70,12 @@ public Path resolveRootDirectory(String command, String[] args, ServerSession se // git-upload-pack means fetch (read operation), git-receive-pack means push (write operation) final var repositoryAction = gitCommand.equals("git-upload-pack") ? RepositoryActionType.READ : gitCommand.equals("git-receive-pack") ? RepositoryActionType.WRITE : null; + final var user = session.getAttribute(SshConstants.USER_KEY); if (session.getAttribute(SshConstants.IS_BUILD_AGENT_KEY) && repositoryAction == RepositoryActionType.READ) { // We already checked for build agent authenticity } else { - final var user = session.getAttribute(SshConstants.USER_KEY); try { localVCServletService.authorizeUser(repositoryTypeOrUserName, user, exercise, repositoryAction, AuthenticationMechanism.SSH, session.getClientAddress().toString(), localVCRepositoryUri); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java index 57330b4d51e0..af1972ce2e1c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/VcsAccessLogService.java @@ -7,6 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.core.domain.User; @@ -44,7 +45,7 @@ public class VcsAccessLogService { * @param commitHash The latest commit hash * @param ipAddress The ip address of the user accessing the repository */ - // TODO: this should be ASYNC to avoid long waiting times during permission check + @Async public void storeAccessLog(User user, ProgrammingExerciseParticipation participation, RepositoryActionType actionType, AuthenticationMechanism authenticationMechanism, String commitHash, String ipAddress) { log.debug("Storing access operation for user {}", user); @@ -55,19 +56,32 @@ public void storeAccessLog(User user, ProgrammingExerciseParticipation participa } /** - * Updates the commit hash after a successful push + * Updates the commit hash of the newest log entry * * @param participation The participation to which the repository belongs to * @param commitHash The newest commit hash which should get set for the access log entry */ - // TODO: this should be ASYNC to avoid long waiting times during permission check + @Async public void updateCommitHash(ProgrammingExerciseParticipation participation, String commitHash) { - vcsAccessLogRepository.findNewestByParticipationIdWhereCommitHashIsNull(participation.getId()).ifPresent(entry -> { + vcsAccessLogRepository.findNewestByParticipationId(participation.getId()).ifPresent(entry -> { entry.setCommitHash(commitHash); vcsAccessLogRepository.save(entry); }); } + /** + * Updates the repository action type of the newest log entry. This method is not Async, as it should already be called from an @Async context + * + * @param participation The participation to which the repository belongs to + * @param repositoryActionType The repositoryActionType which should get set for the newest access log entry + */ + public void updateRepositoryActionType(ProgrammingExerciseParticipation participation, RepositoryActionType repositoryActionType) { + vcsAccessLogRepository.findNewestByParticipationId(participation.getId()).ifPresent(entry -> { + entry.setRepositoryActionType(repositoryActionType); + vcsAccessLogRepository.save(entry); + }); + } + /** * Stores the log for a push from the code editor. * @@ -81,7 +95,11 @@ public void storeCodeEditorAccessLog(Repository repo, User user, Long participat String lastCommitHash = git.log().setMaxCount(1).call().iterator().next().getName(); var participation = participationRepository.findById(participationId); if (participation.isPresent() && participation.get() instanceof ProgrammingExerciseParticipation programmingParticipation) { - storeAccessLog(user, programmingParticipation, RepositoryActionType.WRITE, AuthenticationMechanism.CODE_EDITOR, lastCommitHash, null); + log.debug("Storing access operation for user {}", user); + + VcsAccessLog accessLogEntry = new VcsAccessLog(user, (Participation) programmingParticipation, user.getName(), user.getEmail(), RepositoryActionType.WRITE, + AuthenticationMechanism.CODE_EDITOR, lastCommitHash, null); + vcsAccessLogRepository.save(accessLogEntry); } } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshGitCommand.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshGitCommand.java index b0307ec38bb4..7ec968646217 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshGitCommand.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshGitCommand.java @@ -21,6 +21,7 @@ import org.eclipse.jgit.util.FS; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCFetchPreUploadHookSSH; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCPostPushHook; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCPrePushHook; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCServletService; @@ -84,6 +85,7 @@ public void run() { if (GenericUtils.isNotBlank(protocol)) { uploadPack.setExtraParameters(Collections.singleton(protocol)); } + uploadPack.setPreUploadHook(new LocalVCFetchPreUploadHookSSH(localVCServletService, getServerSession(), rootDir)); uploadPack.upload(getInputStream(), getOutputStream(), getErrorStream()); } else if (RemoteConfig.DEFAULT_RECEIVE_PACK.equals(subCommand)) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryActionType.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryActionType.java index f7f62ebb4989..8d803cc66a53 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryActionType.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/RepositoryActionType.java @@ -4,5 +4,5 @@ * Determines if a repository action only reads (e.g. get a file from the repo) or updates (e.g. create a new file in the repo). */ public enum RepositoryActionType { - READ, WRITE, RESET + READ, WRITE, RESET, CLONE, PULL, PUSH, CLONE_FAIL, PULL_FAIL, PUSH_FAIL }