добавление аудита в таблицу history

This commit is contained in:
Anton Dzyk 2026-01-10 16:33:41 +03:00
parent 11b8f6aa82
commit 3ccb43cb6f
10 changed files with 419 additions and 8 deletions

View File

@ -15,9 +15,10 @@ FROM eclipse-temurin:21-jre
LABEL org.opencontainers.image.title="LTI Provider" LABEL org.opencontainers.image.title="LTI Provider"
LABEL org.opencontainers.image.description="LTI провайдер для лабораторных по Docker и Kubernetes" LABEL org.opencontainers.image.description="LTI провайдер для лабораторных по Docker и Kubernetes"
LABEL org.opencontainers.image.url="TODO" LABEL org.opencontainers.image.url="https://git.oa2.ru/dzyk/lti-provider"
LABEL org.opencontainers.image.source="TODO" LABEL org.opencontainers.image.source="https://git.oa2.ru/dzyk/lti-provider"
LABEL org.opencontainers.image.documentation="#TODO" LABEL org.opencontainers.image.documentation="External Tool для LMS, для лабораторных работо по Docker и Kubernetes.
Поддерживает протокол LTI 1.3. Протестирован с OpenOLAT"
WORKDIR /opt WORKDIR /opt

145
docs/class-diagram.puml Normal file
View File

@ -0,0 +1,145 @@
@startuml LTI Provider
' Настройка для A4 портретной ориентации с ГОСТ отступами (3см слева)
skinparam dpi 150
skinparam defaultFontSize 10
skinparam class {
BackgroundColor<<Controller>> LightBlue
BackgroundColor<<Service>> LightGreen
BackgroundColor<<Entity>> Wheat
BackgroundColor<<Infrastructure>> LightSalmon
FontSize 10
}
skinparam packageStyle rectangle
skinparam linetype polyline
' Слой Controllers - верх по центру
package "Controller Layer" {
class LaunchController <<Controller>> {
+ login()
+ redirect()
}
class TaskController <<Controller>> {
+ getTask()
+ updateTask()
+ submitTask()
}
}
' Левая колонка - Service/Repository/Infrastructure
package "Service Layer" {
class TaskServiceImpl <<Service>> {
+ getTask(UUID): TaskData
+ saveTask(RequestUpdateTask)
+ checkTask(ResultRequest)
}
class JwtService <<Service>> {
+ getPayload(jwt): Payload
+ getTokenPayload(jwt): IdTokenPayload
}
class HistoryService <<Service>> {
+ logEvent()
+ logAction()
}
}
package "Repository Layer" {
interface LMSContentRepository {
+ getLMSContentByContentUuid()
}
interface HistoryRepository {
+ findByContentUuid()
+ findByDeploymentId()
}
}
package "Infrastructure Layer" {
class LMSService <<Infrastructure>> {
+ ltiAuth()
+ exchangeForAccessToken()
}
class Runner <<Infrastructure>> {
+ run(userId, script)
+ check(userId, script)
}
}
' Правая колонка - Entity/Domain
package "Entity Layer" {
class LMSContent <<Entity>> {
- id: long
- contentUuid: UUID
- deploymentId: UUID
- task: Task
}
class Task <<Entity>> {
- id: long
- name: String
- description: String
- initScript: String
- verificationScript: String
- deleteScript: String
}
class History <<Entity>> {
- id: long
- contentUuid: UUID
- message: String
}
}
package "Domain Models" {
class TaskData {
- name: String
- description: String
- initScript: String
- verificationScript: String
- deleteScript: String
}
}
' Вертикальное выравнивание левой колонки
"Controller Layer" -[hidden]down- "Service Layer"
"Service Layer" -[hidden]down- "Repository Layer"
"Repository Layer" -[hidden]down- "Infrastructure Layer"
' Вертикальное выравнивание правой колонки
"Entity Layer" -[hidden]down- "Domain Models"
' Горизонтальное расположение колонок
"Service Layer" -[hidden]right- "Entity Layer"
"Repository Layer" -[hidden]right- "Entity Layer"
"Infrastructure Layer" -[hidden]right- "Domain Models"
' Связи Controllers -> Services
LaunchController --> JwtService
LaunchController --> LMSService
LaunchController --> HistoryService
TaskController --> TaskServiceImpl
TaskController --> HistoryService
' Связи Services -> Infrastructure/Repository
TaskServiceImpl --> LMSContentRepository
TaskServiceImpl --> Runner
TaskServiceImpl --> LMSService
TaskServiceImpl --> HistoryService
TaskServiceImpl ..> TaskData
Runner --> HistoryService
' Связи Repository -> Entity
LMSContentRepository ..> LMSContent
HistoryRepository ..> History
' Связи Entity
LMSContent "*" --> "1" Task
@enduml

39
docs/lti-arhitecture.puml Normal file
View File

@ -0,0 +1,39 @@
@startuml
skinparam backgroundColor white
skinparam componentStyle rectangle
scale 1024 width
' Определяем участников системы
package "Learning Platform" {
[openOLAT] --> [External Tool]
}
package "CI/CD Pipeline" {
[GitLab] --> [GitLab Runner]
[GitLab Runner] --> [Harbor]
}
package "Deployment" {
[Kubernetes]
[Docker]
}
actor User
' Связи
User --> [External Tool]
User --> [GitLab]
User --> [Kubernetes]
User --> [Docker]
[External Tool] --> [GitLab] : init/delete script
[External Tool] --> [Kubernetes] : init/delete script
[Harbor] --> [Kubernetes] : Pull images
"Learning Platform" -[hidden]down- "CI/CD Pipeline"
"Learning Platform" -[hidden]down- "Deployment"
@enduml

View File

@ -6,6 +6,7 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import ru.oa2.lti.application.infrastructure.lms.LMSService; import ru.oa2.lti.application.infrastructure.lms.LMSService;
import ru.oa2.lti.application.service.history.HistoryService;
import ru.oa2.lti.application.service.jwt.JwtService; import ru.oa2.lti.application.service.jwt.JwtService;
import ru.oa2.lti.domain.model.auth.LtiLogin; import ru.oa2.lti.domain.model.auth.LtiLogin;
@ -21,11 +22,14 @@ public class LaunchController {
private final JwtService jwtService; private final JwtService jwtService;
private final LMSService lmsService; private final LMSService lmsService;
private final HistoryService historyService;
public LaunchController(JwtService jwtService, public LaunchController(JwtService jwtService,
LMSService lmsService) { LMSService lmsService,
HistoryService historyService) {
this.jwtService = jwtService; this.jwtService = jwtService;
this.lmsService = lmsService; this.lmsService = lmsService;
this.historyService = historyService;
} }
@ResponseBody @ResponseBody
@ -57,6 +61,14 @@ public class LaunchController {
session.setAttribute("lti_user_id", ltiLogin.getClientId()); session.setAttribute("lti_user_id", ltiLogin.getClientId());
session.setAttribute("payload", payload); session.setAttribute("payload", payload);
// Логирование события входа
historyService.logAction(
payload.getDeploymentId(),
payload.getContextId(),
"LTI_LOGIN",
String.format("User login via LTI 1.3, clientId=%s", clientId)
);
if (payload.getContextId() != null) { if (payload.getContextId() != null) {
return lmsService.ltiAuth(ltiMessageHint, iss, loginHint); return lmsService.ltiAuth(ltiMessageHint, iss, loginHint);
} }
@ -79,6 +91,17 @@ public class LaunchController {
session.setAttribute("id_token", idToken); session.setAttribute("id_token", idToken);
session.setAttribute("state", state); session.setAttribute("state", state);
// Декодируем id_token для получения информации о контексте
var idTokenPayload = jwtService.getTokenPayload(idToken);
// Логирование перенаправления после авторизации
historyService.logAction(
idTokenPayload.getDeploymentId(),
idTokenPayload.getContext() != null ? idTokenPayload.getContext().id() : null,
"LTI_REDIRECT",
"User redirected after OAuth authentication"
);
return "redirect:/tool/lti/task"; return "redirect:/tool/lti/task";
} }
} }

View File

@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import ru.oa2.lti.application.service.history.HistoryService;
import ru.oa2.lti.application.service.task.TaskService; import ru.oa2.lti.application.service.task.TaskService;
import ru.oa2.lti.domain.model.ResultRequest; import ru.oa2.lti.domain.model.ResultRequest;
import ru.oa2.lti.domain.model.auth.LtiLogin; import ru.oa2.lti.domain.model.auth.LtiLogin;
@ -33,9 +34,11 @@ POST /submit - отправка на автоматическую проверк
public class TaskController { public class TaskController {
private final TaskService taskService; private final TaskService taskService;
private final HistoryService historyService;
public TaskController(TaskService taskService) { public TaskController(TaskService taskService, HistoryService historyService) {
this.taskService = taskService; this.taskService = taskService;
this.historyService = historyService;
} }
@GetMapping @GetMapping
@ -49,12 +52,26 @@ public class TaskController {
var data = taskService.getTask(payload.getContextId()); var data = taskService.getTask(payload.getContextId());
if (data == null) { if (data == null) {
historyService.logAction(
payload.getDeploymentId(),
payload.getContextId(),
"TASK_ERROR",
"Task not found"
);
return "redirect:/error"; return "redirect:/error";
} }
model.addAttribute("name", data.getName()); model.addAttribute("name", data.getName());
model.addAttribute("description", data.getDescription()); model.addAttribute("description", data.getDescription());
// Логирование просмотра задачи
historyService.logAction(
payload.getDeploymentId(),
payload.getContextId(),
"TASK_VIEW",
String.format("Task viewed: %s, isCoach=%s", data.getName(), payload.getCoach())
);
if (Objects.requireNonNull(payload).getCoach()) { if (Objects.requireNonNull(payload).getCoach()) {
model.addAttribute("initScript", data.getInitScript()); model.addAttribute("initScript", data.getInitScript());
model.addAttribute("checkScript", data.getVerificationScript()); model.addAttribute("checkScript", data.getVerificationScript());
@ -75,6 +92,14 @@ public class TaskController {
var payload = (Payload) session.getAttribute("payload"); var payload = (Payload) session.getAttribute("payload");
if (payload != null && payload.getCoach()) { if (payload != null && payload.getCoach()) {
// Логирование обновления задачи
historyService.logAction(
payload.getDeploymentId(),
payload.getContextId(),
"TASK_UPDATE",
String.format("Task updated by coach: %s", data.getName())
);
return ResponseEntity.accepted().body( return ResponseEntity.accepted().body(
taskService.saveTask(RequestUpdateTask.builder() taskService.saveTask(RequestUpdateTask.builder()
.contextId(payload.getContextId()) .contextId(payload.getContextId())
@ -82,6 +107,17 @@ public class TaskController {
.build()) .build())
); );
} }
// Логирование попытки несанкционированного обновления
if (payload != null) {
historyService.logAction(
payload.getDeploymentId(),
payload.getContextId(),
"TASK_UPDATE_DENIED",
"Unauthorized task update attempt"
);
}
return ResponseEntity.status(HttpStatusCode.valueOf(403)) return ResponseEntity.status(HttpStatusCode.valueOf(403))
.body(new ResultResponse("forbidden")); .body(new ResultResponse("forbidden"));
} }
@ -94,6 +130,17 @@ public class TaskController {
var ltiLogin = (LtiLogin) session.getAttribute("lti_login"); var ltiLogin = (LtiLogin) session.getAttribute("lti_login");
var idToken = (String) session.getAttribute("id_token"); var idToken = (String) session.getAttribute("id_token");
var payload = (Payload) session.getAttribute("payload");
// Логирование отправки задачи на проверку
if (payload != null) {
historyService.logAction(
payload.getDeploymentId(),
payload.getContextId(),
"TASK_SUBMIT",
String.format("Task submitted for checking, clientId=%s", ltiLogin.getClientId())
);
}
return ResponseEntity return ResponseEntity
.accepted() .accepted()

View File

@ -0,0 +1,16 @@
package ru.oa2.lti.application.infrastructure.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.oa2.lti.application.infrastructure.repository.entities.History;
import java.util.List;
import java.util.UUID;
@Repository
public interface HistoryRepository extends JpaRepository<History, Long> {
List<History> findByContentUuidOrderByCreatedDesc(UUID contentUuid);
List<History> findByDeploymentIdOrderByCreatedDesc(UUID deploymentId);
}

View File

@ -2,6 +2,7 @@ package ru.oa2.lti.application.infrastructure.runner;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import ru.oa2.lti.application.service.history.HistoryService;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
@ -17,13 +18,28 @@ import java.util.UUID;
public class RunnerImpl implements Runner { public class RunnerImpl implements Runner {
private final String TEMP_DIR = "./temp/"; private final String TEMP_DIR = "./temp/";
private final HistoryService historyService;
public RunnerImpl(HistoryService historyService) {
this.historyService = historyService;
}
@Override @Override
public boolean run(UUID userId, String script) { public boolean run(UUID userId, String script) {
try { try {
return runScript(userId, script); historyService.logAction(null, userId, "SCRIPT_RUN_START", "Init script execution started");
boolean result = runScript(userId, script);
if (result) {
historyService.logAction(null, userId, "SCRIPT_RUN_SUCCESS", "Init script completed successfully");
} else {
historyService.logAction(null, userId, "SCRIPT_RUN_FAILED", "Init script failed with non-zero exit code");
}
return result;
} catch (Exception ex) { } catch (Exception ex) {
log.error(ex.getMessage()); log.error(ex.getMessage());
historyService.logAction(null, userId, "SCRIPT_RUN_ERROR", "Init script error: " + ex.getMessage());
return false; return false;
} }
} }
@ -32,9 +48,19 @@ public class RunnerImpl implements Runner {
@Override @Override
public boolean check(UUID userId, String script) { public boolean check(UUID userId, String script) {
try { try {
return runScript(userId, script); historyService.logAction(null, userId, "SCRIPT_CHECK_START", "Verification script execution started");
boolean result = runScript(userId, script);
if (result) {
historyService.logAction(null, userId, "SCRIPT_CHECK_SUCCESS", "Verification script completed successfully");
} else {
historyService.logAction(null, userId, "SCRIPT_CHECK_FAILED", "Verification script failed");
}
return result;
} catch (Exception ex) { } catch (Exception ex) {
log.error(ex.getMessage()); log.error(ex.getMessage());
historyService.logAction(null, userId, "SCRIPT_CHECK_ERROR", "Verification script error: " + ex.getMessage());
return false; return false;
} }
} }
@ -42,6 +68,7 @@ public class RunnerImpl implements Runner {
@Override @Override
public boolean delete(UUID userId, String script) { public boolean delete(UUID userId, String script) {
//TODO реализовать метод удаления сущностей //TODO реализовать метод удаления сущностей
historyService.logAction(null, userId, "SCRIPT_DELETE_CALLED", "Delete script called (not implemented)");
return false; return false;
} }

View File

@ -0,0 +1,25 @@
package ru.oa2.lti.application.service.history;
import java.util.UUID;
public interface HistoryService {
/**
* Логирование события
*
* @param deploymentId ID деплоймента LMS
* @param contentUuid UUID контента
* @param message Сообщение о событии
*/
void logEvent(UUID deploymentId, UUID contentUuid, String message);
/**
* Логирование события с автоматической меткой времени
*
* @param deploymentId ID деплоймента LMS
* @param contentUuid UUID контента
* @param action Действие (например, "LOGIN", "TASK_LOADED")
* @param details Детали события
*/
void logAction(UUID deploymentId, UUID contentUuid, String action, String details);
}

View File

@ -0,0 +1,61 @@
package ru.oa2.lti.application.service.history;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import ru.oa2.lti.application.infrastructure.repository.HistoryRepository;
import ru.oa2.lti.application.infrastructure.repository.entities.History;
import java.time.LocalDateTime;
import java.util.UUID;
@Slf4j
@Service
public class HistoryServiceImpl implements HistoryService {
private final HistoryRepository historyRepository;
public HistoryServiceImpl(HistoryRepository historyRepository) {
this.historyRepository = historyRepository;
}
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logEvent(UUID deploymentId, UUID contentUuid, String message) {
try {
History history = new History();
history.setDeploymentId(deploymentId);
history.setContentUuid(contentUuid);
history.setCreated(LocalDateTime.now());
history.setMessage(truncateMessage(message));
historyRepository.save(history);
log.debug("Event logged: deploymentId={}, contentUuid={}, message={}",
deploymentId, contentUuid, message);
} catch (Exception e) {
log.error("Failed to log event: deploymentId={}, contentUuid={}, message={}",
deploymentId, contentUuid, message, e);
}
}
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAction(UUID deploymentId, UUID contentUuid, String action, String details) {
String message = String.format("[%s] %s", action, details);
logEvent(deploymentId, contentUuid, message);
}
/**
* Обрезка сообщения до максимальной длины (2048 символов)
*/
private String truncateMessage(String message) {
if (message == null) {
return "";
}
if (message.length() > 2048) {
return message.substring(0, 2045) + "...";
}
return message;
}
}

View File

@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import ru.oa2.lti.application.infrastructure.lms.LMSService; import ru.oa2.lti.application.infrastructure.lms.LMSService;
import ru.oa2.lti.application.infrastructure.repository.LMSContentRepository; import ru.oa2.lti.application.infrastructure.repository.LMSContentRepository;
import ru.oa2.lti.application.service.history.HistoryService;
import ru.oa2.lti.application.infrastructure.repository.entities.LMSContent; import ru.oa2.lti.application.infrastructure.repository.entities.LMSContent;
import ru.oa2.lti.application.infrastructure.repository.entities.Task; import ru.oa2.lti.application.infrastructure.repository.entities.Task;
import ru.oa2.lti.application.infrastructure.runner.Runner; import ru.oa2.lti.application.infrastructure.runner.Runner;
@ -23,6 +24,7 @@ public class TaskServiceImpl implements TaskService {
private final LMSContentRepository lmsContentRepository; private final LMSContentRepository lmsContentRepository;
private final Runner runner; private final Runner runner;
private final HistoryService historyService;
//TODO объединить в один? //TODO объединить в один?
final ResultService resultService; final ResultService resultService;
@ -31,12 +33,14 @@ public class TaskServiceImpl implements TaskService {
public TaskServiceImpl(LMSContentRepository lmsContentRepository, public TaskServiceImpl(LMSContentRepository lmsContentRepository,
Runner runner, Runner runner,
ResultService resultService, ResultService resultService,
LMSService lmsService) { LMSService lmsService,
HistoryService historyService) {
this.lmsContentRepository = lmsContentRepository; this.lmsContentRepository = lmsContentRepository;
this.runner = runner; this.runner = runner;
this.resultService = resultService; this.resultService = resultService;
this.lmsService = lmsService; this.lmsService = lmsService;
this.historyService = historyService;
} }
@Override @Override
@ -88,9 +92,32 @@ public class TaskServiceImpl implements TaskService {
Task task = content.getTask(); Task task = content.getTask();
if (task != null) { if (task != null) {
String oldName = task.getName();
updateTaskFromData(task, requestUpdateTask.getData()); updateTaskFromData(task, requestUpdateTask.getData());
lmsContentRepository.save(content); lmsContentRepository.save(content);
historyService.logAction(
content.getDeploymentId(),
requestUpdateTask.getContextId(),
"TASK_SAVED",
String.format("Task saved: %s (was: %s)",
requestUpdateTask.getData().getName(), oldName)
);
} else {
historyService.logAction(
content.getDeploymentId(),
requestUpdateTask.getContextId(),
"TASK_SAVE_ERROR",
"Task is null, cannot save"
);
} }
} else {
historyService.logAction(
null,
requestUpdateTask.getContextId(),
"TASK_SAVE_ERROR",
"Content not found"
);
} }
//TODO обработка exception - failed / success //TODO обработка exception - failed / success