From c6ca981eca2ac34f9d6d0fdb2e9745be49e8d3f9 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 27 Oct 2026 20:39:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(progress):=20=D0=BF=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B5=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D1=8B=20=D0=B8=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ru/ysu/lms/progress/AcademicClient.kt | 26 + .../ru/ysu/lms/progress/CourseScopeService.kt | 78 +++ .../lms/progress/DefenseAdminController.kt | 134 +++-- .../ru/ysu/lms/progress/DefenseService.kt | 7 + .../main/kotlin/ru/ysu/lms/progress/Dtos.kt | 16 + .../lms/progress/GroupLabDataController.kt | 27 +- .../ru/ysu/lms/progress/ProgressController.kt | 139 ++++- .../ru/ysu/lms/progress/ProgressService.kt | 35 +- .../ysu/lms/progress/SubmissionController.kt | 17 + .../ru/ysu/lms/progress/audit/AuditClient.kt | 89 ++++ .../lms/progress/profile/ProfileController.kt | 49 +- .../ysu/lms/progress/profile/ProfileDtos.kt | 10 + .../progress/profile/StudentProfileService.kt | 10 +- .../progress/ProgressControllerAuditTest.kt | 84 +++ .../ProgressDistributionIntegrationTest.kt | 40 +- .../progress/StudentProfileIntegrationTest.kt | 17 +- .../TeacherLabPageAccessIntegrationTest.kt | 61 ++- .../TeacherWriteAccessIntegrationTest.kt | 478 ++++++++++++++++++ .../ysu/lms/progress/audit/AuditClientTest.kt | 88 ++++ 19 files changed, 1302 insertions(+), 103 deletions(-) create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/CourseScopeService.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/audit/AuditClient.kt create mode 100644 services/progress-service/src/test/kotlin/ru/ysu/lms/progress/ProgressControllerAuditTest.kt create mode 100644 services/progress-service/src/test/kotlin/ru/ysu/lms/progress/TeacherWriteAccessIntegrationTest.kt create mode 100644 services/progress-service/src/test/kotlin/ru/ysu/lms/progress/audit/AuditClientTest.kt diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt index 2222ebd..dc252e5 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt @@ -61,6 +61,24 @@ class AcademicClient( throw courseError(ex) } + /** Состав группы, активно привязанной к курсу (вместе с проверкой доступа academic). */ + fun getCourseGroupStudents( + courseId: String, + groupId: String, + token: String, + ): CourseGroupRosterInfo = + try { + client + .get() + .uri("/api/v1/courses/{courseId}/groups/{groupId}/students", courseId, groupId) + .header("Authorization", "Bearer $token") + .retrieve() + .body(CourseGroupRosterInfo::class.java) + ?: throw NotFoundException("Группа не найдена") + } catch (ex: RestClientResponseException) { + throw groupError(ex) + } + /** * Лабораторные курса вместе с уровнями и вариантами: список отдаёт только сводки, * поэтому детали каждой лабораторной подтягиваются отдельно. Ошибка детали не @@ -112,6 +130,14 @@ private fun labError(ex: RestClientResponseException): Exception = else -> ex } +/** 403 academic означает чужую группу или чужой курс. */ +private fun groupError(ex: RestClientResponseException): Exception = + when (ex.statusCode.value()) { + 403 -> ForbiddenException("Группа недоступна") + 404 -> NotFoundException("Группа не найдена") + else -> ex + } + @Service class IdentityClient( @Value("\${lms.identity.base-url}") baseUrl: String, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/CourseScopeService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/CourseScopeService.kt new file mode 100644 index 0000000..e84bb95 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/CourseScopeService.kt @@ -0,0 +1,78 @@ +package ru.ysu.lms.progress + +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.stereotype.Service +import ru.ysu.lms.common.ForbiddenException +import java.util.UUID + +/** + * Единая проверка курсовой области для преподавателя: чужая лабораторная, студент + * или группа вне активных групп курса должны давать 403 до любой записи. + * Администратор проверки не проходит — у него полный доступ. + */ +@Service +class CourseScopeService( + private val academic: AcademicClient, + private val identity: IdentityClient, +) { + fun lab( + labId: UUID, + token: String, + ): LabInfo = academic.getLab(labId.toString(), token) + + fun courseIdOf( + labId: UUID, + token: String, + ): UUID { + val lab = lab(labId, token) + val courseId = lab.courseId?.takeIf { it.isNotBlank() } ?: throw ForbiddenException("Лабораторная недоступна") + return UUID.fromString(courseId) + } + + fun requireStudentInCourse( + studentId: UUID, + courseId: UUID, + token: String, + ) { + val groupId = + identity + .getStudentContext(studentId.toString()) + .groupId + ?.takeIf { it.isNotBlank() } + ?: throw ForbiddenException("Студент не входит в группу курса") + val activeGroups = courseGroups(courseId, token).map { it.groupId } + if (groupId !in activeGroups) throw ForbiddenException("Студент не входит в группу курса") + } + + fun requireGroupInCourse( + groupId: UUID, + courseId: UUID, + token: String, + ) { + val activeGroups = courseGroups(courseId, token).map { it.groupId } + if (groupId.toString() !in activeGroups) throw ForbiddenException("Группа не привязана к курсу") + } + + fun role(jwt: Jwt): String? = jwt.getClaimAsString("role") + + fun isAdmin(jwt: Jwt): Boolean = role(jwt) == ROLE_ADMIN + + fun course( + courseId: UUID, + token: String, + ): CourseInfo = academic.getCourse(courseId.toString(), token) + + fun courseLabs( + courseId: UUID, + token: String, + ): List = academic.getCourseLabs(courseId.toString(), token) + + fun courseGroups( + courseId: UUID, + token: String, + ): List = course(courseId, token).groups.filter { it.removedAt == null } + + private companion object { + const val ROLE_ADMIN = "ADMIN" + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt index 4df0c57..7ff82f2 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseAdminController.kt @@ -1,6 +1,8 @@ package ru.ysu.lms.progress import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -9,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.progress.audit.AuditClient import java.util.UUID data class DefenseAssignPayload( @@ -32,71 +35,136 @@ data class DefenseAdminDto( @RestController @RequestMapping("/api/v1/defenses") -@PreAuthorize("hasAuthority('ROLE_ADMIN')") +@PreAuthorize("hasAnyRole('ADMIN','TEACHER')") class DefenseAdminController( private val service: DefenseService, + private val scope: CourseScopeService, + private val audit: AuditClient, ) { @PostMapping("/assign") fun assign( @RequestBody body: DefenseAssignPayload, - ): DefenseDto = service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd) + @AuthenticationPrincipal jwt: Jwt, + ): DefenseDto { + requireStudentScope(body.studentId, body.labId, jwt) + val defense = + service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd) + audit.record( + actorAccountId = jwt.subject, + action = "DEFENSE_ASSIGN", + entityType = "DEFENSE", + entityId = defense.id, + studentId = body.studentId, + details = "lab=${body.labId}", + ) + return defense + } @DeleteMapping("/assign") fun removeAssignment( @RequestParam studentId: String, @RequestParam labId: String, + @AuthenticationPrincipal jwt: Jwt, ) { + requireStudentScope(studentId, labId, jwt) service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId)) + audit.record( + actorAccountId = jwt.subject, + action = "DEFENSE_REMOVE", + entityType = "DEFENSE", + entityId = "$studentId|$labId", + studentId = studentId, + details = "lab=$labId", + ) } @GetMapping fun list( @RequestParam labId: UUID, - ): List = - service.listForLab(labId).map { - DefenseAdminDto( - it.id, - it.studentId, - it.labId, - it.status, - it.code, - it.assignmentMd, - it.startedAt, - it.readyAt, - it.readyReason, - it.secondsLeft, - ) - } + @AuthenticationPrincipal jwt: Jwt, + ): List { + requireLabScope(labId, jwt) + return service.listForLab(labId).map { toAdminDto(it) } + } /** Синхронизация таймеров статусов по списку лабораторных: `labs=id1,id2,...` */ @GetMapping("/watch") fun watch( @RequestParam labs: String, + @AuthenticationPrincipal jwt: Jwt, ): List { val ids = labs.split(',').mapNotNull { runCatching { UUID.fromString(it.trim()) }.getOrNull() } - return service.listForLabs(ids).map { - DefenseAdminDto( - it.id, - it.studentId, - it.labId, - it.status, - it.code, - it.assignmentMd, - it.startedAt, - it.readyAt, - it.readyReason, - it.secondsLeft, - ) - } + if (!scope.isAdmin(jwt)) ids.forEach { requireLabScope(it, jwt) } + return service.listForLabs(ids).map { toAdminDto(it) } } @PostMapping("/{id}/issue-code") fun issueCode( @PathVariable id: UUID, - ): Map = mapOf("code" to service.issueCode(id)) + @AuthenticationPrincipal jwt: Jwt, + ): Map { + val session = service.findSession(id) + requireStudentScope(session.studentId, session.labId, jwt) + val code = service.issueCode(id) + audit.record( + actorAccountId = jwt.subject, + action = "DEFENSE_CODE_ISSUE", + entityType = "DEFENSE", + entityId = id.toString(), + studentId = session.studentId, + details = "lab=${session.labId}", + ) + return mapOf("code" to code) + } @PostMapping("/{id}/reset") fun reset( @PathVariable id: UUID, - ) = service.reset(id) + @AuthenticationPrincipal jwt: Jwt, + ) { + val session = service.findSession(id) + requireLabScope(UUID.fromString(session.labId), jwt) + service.reset(id) + audit.record( + actorAccountId = jwt.subject, + action = "DEFENSE_RESET", + entityType = "DEFENSE", + entityId = id.toString(), + studentId = session.studentId, + details = "lab=${session.labId}", + ) + } + + private fun requireLabScope( + labId: UUID, + jwt: Jwt, + ) { + if (scope.isAdmin(jwt)) return + scope.lab(labId, jwt.tokenValue) + } + + private fun requireStudentScope( + studentId: String, + labId: String, + jwt: Jwt, + ) { + if (scope.isAdmin(jwt)) return + val token = jwt.tokenValue + val courseId = scope.courseIdOf(UUID.fromString(labId), token) + scope.requireStudentInCourse(UUID.fromString(studentId), courseId, token) + } + + private fun toAdminDto(it: DefenseDto): DefenseAdminDto = + DefenseAdminDto( + it.id, + it.studentId, + it.labId, + it.status, + it.code, + it.assignmentMd, + it.startedAt, + it.readyAt, + it.readyReason, + it.secondsLeft, + ) } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt index 2ee1469..941c641 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/DefenseService.kt @@ -73,6 +73,13 @@ class DefenseService( return toDto(sessions.save(s)) } + @Transactional(readOnly = true) + fun findSession(id: UUID): DefenseDto = + sessions + .findById(id) + .orElseThrow { NotFoundException("Сессия защиты не найдена") } + .let { toDto(it) } + @Transactional fun issueCode(id: UUID): String { val s = sessions.findById(id).orElseThrow { NotFoundException("Сессия защиты не найдена") } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt index 00fb49a..b4c0e8b 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/Dtos.kt @@ -87,6 +87,22 @@ data class CourseGroupInfo( val removedAt: String? = null, ) +/** Состав группы в разрезе курса: привязка проверяется academic-service. */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseGroupRosterInfo( + val groupId: String = "", + val groupName: String = "", + val students: List = emptyList(), +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CourseGroupStudentInfo( + val id: String = "", + val studentNo: String = "", + val fullName: String = "", + val status: String = "", +) + /** Внутренний контекст студента из identity-service. */ @JsonIgnoreProperties(ignoreUnknown = true) data class StudentContext( diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GroupLabDataController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GroupLabDataController.kt index 5d50eba..d4a7584 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GroupLabDataController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GroupLabDataController.kt @@ -7,18 +7,39 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.common.BadRequestException import java.util.UUID @RestController class GroupLabDataController( private val service: GroupLabDataService, + private val scope: CourseScopeService, ) { - /** Данные всех студентов группы по выбранным лабораторным — только для админа. */ + /** Данные всех студентов группы по выбранным лабораторным — для админа и преподавателя своего курса. */ @GetMapping("/api/v1/assignments/group/{groupId}") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun groupLabData( @PathVariable groupId: UUID, @RequestParam("labId") labIds: List, @AuthenticationPrincipal jwt: Jwt, - ): GroupLabDataResponse = service.labData(groupId, labIds, jwt.tokenValue) + ): GroupLabDataResponse { + requireScope(groupId, labIds, jwt) + return service.labData(groupId, labIds, jwt.tokenValue) + } + + private fun requireScope( + groupId: UUID, + labIds: List, + jwt: Jwt, + ) { + if (scope.isAdmin(jwt) || labIds.isEmpty()) return + val token = jwt.tokenValue + val courseId = scope.courseIdOf(labIds.first(), token) + for (labId in labIds.drop(1)) { + if (scope.courseIdOf(labId, token) != courseId) { + throw BadRequestException("Лабораторные относятся к разным курсам") + } + } + scope.requireGroupInCourse(groupId, courseId, token) + } } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt index 8116231..a90c8a8 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -11,6 +11,8 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.common.BadRequestException +import ru.ysu.lms.progress.audit.AuditClient import java.util.UUID @RestController @@ -19,6 +21,8 @@ class ProgressController( private val service: ProgressService, private val grades: GradeService, private val publications: GradePublicationService, + private val scope: CourseScopeService, + private val audit: AuditClient, ) { @GetMapping("/me/assignments") fun meAssignments( @@ -38,7 +42,19 @@ class ProgressController( fun setLevel( @Valid @RequestBody body: LevelAssignmentRequest, @AuthenticationPrincipal jwt: Jwt, - ): LevelAssignmentResponse = service.setLevel(body, jwt.tokenValue) + ): LevelAssignmentResponse { + requireStudentScope(body.studentId, body.labId, jwt) + val response = service.setLevel(body, jwt.tokenValue) + audit.record( + actorAccountId = jwt.subject, + action = "VARIANT_LEVEL_SET", + entityType = "LAB", + entityId = body.labId, + studentId = body.studentId, + details = "level=${body.labLevelId}", + ) + return response + } @GetMapping("/levels") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") @@ -56,18 +72,40 @@ class ProgressController( } @PostMapping("/distribute") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun distribute( @Valid @RequestBody body: DistributeRequest, @AuthenticationPrincipal jwt: Jwt, - ): DistributeResult = service.distribute(body, jwt.tokenValue) + ): DistributeResult { + val result = service.distribute(body, jwt.tokenValue) + audit.record( + actorAccountId = jwt.subject, + action = "VARIANT_DISTRIBUTE", + entityType = "LAB", + entityId = result.labId ?: requestedLabIds(body).joinToString(","), + details = "assigned=${result.assigned}/${result.total}", + ) + return result + } @PostMapping("/assign") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun assign( @Valid @RequestBody body: AssignRequest, @AuthenticationPrincipal jwt: Jwt, - ): VariantAssignmentResponse = service.assignWithInfo(body, jwt.tokenValue) + ): VariantAssignmentResponse { + requireStudentScope(body.studentId, body.labId, jwt) + val response = service.assignWithInfo(body, jwt.tokenValue) + audit.record( + actorAccountId = jwt.subject, + action = "VARIANT_ASSIGN", + entityType = "LAB", + entityId = body.labId, + studentId = body.studentId, + details = "variant=${body.variantId}", + ) + return response + } @GetMapping("/assignments") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") @@ -77,48 +115,84 @@ class ProgressController( ): List = service.assignmentsForLabEnriched(labId, jwt.tokenValue) @PostMapping("/grades") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun setGrade( @Valid @RequestBody body: GradeSetRequest, @AuthenticationPrincipal jwt: Jwt, - ): GradeResponse = grades.setGrade(body, UUID.fromString(jwt.subject)) + ): GradeResponse { + requireStudentScope(body.studentId, body.labId, jwt) + val response = grades.setGrade(body, UUID.fromString(jwt.subject)) + audit.record( + actorAccountId = jwt.subject, + action = "GRADE_SET", + entityType = "GRADE", + entityId = "${body.studentId}|${body.labId}", + studentId = body.studentId, + details = "score=${body.score.toPlainString()}", + ) + return response + } @GetMapping("/grades") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") - fun listGrades(): List = grades.listGrades() + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") + fun listGrades( + @RequestParam(required = false) courseId: UUID?, + @AuthenticationPrincipal jwt: Jwt, + ): List { + if (scope.isAdmin(jwt)) return grades.listGrades() + val cid = courseId ?: throw BadRequestException("Укажите курс") + scope.course(cid, jwt.tokenValue) + val labIds = scope.courseLabs(cid, jwt.tokenValue).map { it.id }.toSet() + return grades.listGrades().filter { it.labId in labIds } + } @GetMapping("/grades/publication") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun publicationState( - @RequestParam groupId: java.util.UUID, - @RequestParam labId: java.util.UUID, - ): PublicationState = publications.state(groupId, labId) + @RequestParam groupId: UUID, + @RequestParam labId: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): PublicationState { + requirePublicationScope(groupId, labId, jwt) + return publications.state(groupId, labId) + } @PostMapping("/grades/publication") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun setPublication( @RequestBody body: PublicationSetRequest, + @AuthenticationPrincipal jwt: Jwt, ) { + val groupId = UUID.fromString(body.groupId) + val labId = UUID.fromString(body.labId) + requirePublicationScope(groupId, labId, jwt) publications.setStudents( - java.util.UUID.fromString(body.groupId), - java.util.UUID.fromString(body.labId), - body.studentIds.map { java.util.UUID.fromString(it) }, + groupId, + labId, + body.studentIds.map { UUID.fromString(it) }, + ) + audit.record( + actorAccountId = jwt.subject, + action = "GRADE_PUBLISH", + entityType = "GRADE_PUBLICATION", + entityId = "$groupId|$labId", + details = "students=${body.studentIds.size};group=$groupId", ) } @GetMapping("/me/grades") fun myGrades( @AuthenticationPrincipal jwt: Jwt, - @RequestParam(required = false) groupId: java.util.UUID?, + @RequestParam(required = false) groupId: UUID?, ): List { val all = grades.listGrades().filter { it.studentId == jwt.subject } if (groupId == null) return emptyList() - val studentId = java.util.UUID.fromString(jwt.subject) - return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId), studentId) } + val studentId = UUID.fromString(jwt.subject) + return all.filter { publications.isPublished(groupId, UUID.fromString(it.labId), studentId) } } @GetMapping("/comment-templates") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") fun suggestComments( @RequestParam q: String, ): List = grades.suggestComments(q) @@ -130,4 +204,29 @@ class ProgressController( ) { grades.deleteCommentTemplate(text) } + + private fun requestedLabIds(body: DistributeRequest): List = body.labIds.orEmpty().ifEmpty { listOfNotNull(body.labId) } + + /** Преподаватель пишет только по своим лабораторным и только студентам групп своего курса. */ + private fun requireStudentScope( + studentId: String, + labId: String, + jwt: Jwt, + ) { + if (scope.isAdmin(jwt)) return + val token = jwt.tokenValue + val courseId = scope.courseIdOf(UUID.fromString(labId), token) + scope.requireStudentInCourse(UUID.fromString(studentId), courseId, token) + } + + private fun requirePublicationScope( + groupId: UUID, + labId: UUID, + jwt: Jwt, + ) { + if (scope.isAdmin(jwt)) return + val token = jwt.tokenValue + val courseId = scope.courseIdOf(labId, token) + scope.requireGroupInCourse(groupId, courseId, token) + } } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt index caead8f..73a9eea 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt @@ -3,6 +3,7 @@ package ru.ysu.lms.progress import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import ru.ysu.lms.common.BadRequestException +import ru.ysu.lms.common.ForbiddenException import ru.ysu.lms.progress.domain.StudentLabLevel import ru.ysu.lms.progress.domain.StudentLabLevelKey import ru.ysu.lms.progress.domain.VariantAssignment @@ -15,7 +16,6 @@ class ProgressService( private val levels: StudentLabLevelRepository, private val assignments: VariantAssignmentRepository, private val academic: AcademicClient, - private val identity: IdentityClient, ) { @Transactional fun setLevel( @@ -92,15 +92,17 @@ class ProgressService( token: String, ): DistributeResult { val labIds = resolveLabIds(request) + // Каждую лабораторную читаем один раз: academic сам отсекает чужие (403). + val labs = labIds.map { it to academic.getLab(it, token) } + val courseId = courseIdOf(labs.map { it.second }) val force = request.force == true - val allStudents = collectStudentIds(request, token) + val allStudents = collectStudentIds(request, courseId, token) var assignedTotal = 0 var totalStudents = 0 var lastLabId: String? = null var lastLabTitle: String? = null - for (labIdStr in labIds) { + for ((labIdStr, lab) in labs) { val labId = UUID.fromString(labIdStr) - val lab = academic.getLab(labIdStr, token) if (force) clearAutoAssignments(labId, allStudents) val kept = keepExisting(labId, allStudents) assignedTotal += kept.size @@ -118,6 +120,16 @@ class ProgressService( ) } + private fun courseIdOf(labs: List): UUID { + val courseId = + labs.firstOrNull()?.courseId?.takeIf { it.isNotBlank() } + ?: throw ForbiddenException("Лабораторная недоступна") + if (labs.any { it.courseId != courseId }) { + throw BadRequestException("Лабораторные относятся к разным курсам") + } + return UUID.fromString(courseId) + } + private fun resolveLabIds(request: DistributeRequest): List { val labIds = request.labIds @@ -130,8 +142,10 @@ class ProgressService( return labIds } + /** Студенты активных групп курса: состав берётся из academic, а не из всех групп identity. */ private fun collectStudentIds( request: DistributeRequest, + courseId: UUID, token: String, ): List { val groupFilter = @@ -139,11 +153,16 @@ class ProgressService( .orEmpty() .filter { it.isNotBlank() } .toSet() + val activeGroups = academic.getCourse(courseId.toString(), token).groups.filter { it.removedAt == null } + val activeGroupIds = activeGroups.map { it.groupId } + if (groupFilter.isNotEmpty() && !activeGroupIds.containsAll(groupFilter)) { + throw ForbiddenException("Группа не привязана к курсу") + } + val selected = if (groupFilter.isEmpty()) activeGroupIds else activeGroupIds.filter { it in groupFilter } val studentIds = mutableListOf() - for (g in identity.listGroups(token)) { - val gid = g["id"] as String - if (groupFilter.isNotEmpty() && gid !in groupFilter) continue - for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id)) + for (groupId in selected) { + val roster = academic.getCourseGroupStudents(courseId.toString(), groupId, token) + for (student in roster.students) studentIds.add(UUID.fromString(student.id)) } return studentIds.distinct() } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionController.kt index bddfb07..ff79e3c 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionController.kt @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile +import ru.ysu.lms.progress.audit.AuditClient import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.util.UUID @@ -22,6 +23,7 @@ import java.util.UUID @RequestMapping("/api/v1") class SubmissionController( private val submissions: SubmissionService, + private val audit: AuditClient, ) { @PostMapping("/me/submissions/{labId}") fun submit( @@ -47,6 +49,14 @@ class SubmissionController( ): ResponseEntity { val sid = UUID.fromString(jwt.subject) val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false) + audit.record( + actorAccountId = sid.toString(), + action = "SUBMISSION_DOWNLOAD_SELF", + entityType = "SUBMISSION", + entityId = id.toString(), + studentId = sid.toString(), + details = "self", + ) return attachment(bytes, name) } @@ -64,6 +74,13 @@ class SubmissionController( @AuthenticationPrincipal jwt: Jwt, ): ResponseEntity { val (bytes, name) = submissions.downloadForStaff(id, jwt.tokenValue) + audit.record( + actorAccountId = jwt.subject, + action = "SUBMISSION_DOWNLOAD", + entityType = "SUBMISSION", + entityId = id.toString(), + details = "staff", + ) return attachment(bytes, name) } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/audit/AuditClient.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/audit/AuditClient.kt new file mode 100644 index 0000000..2496b12 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/audit/AuditClient.kt @@ -0,0 +1,89 @@ +package ru.ysu.lms.progress.audit + +import com.fasterxml.jackson.annotation.JsonInclude +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.http.client.JdkClientHttpRequestFactory +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient +import java.net.http.HttpClient +import java.time.Duration + +/** + * Best-effort запись действий в аудит identity-service. + * Ошибка аудита не должна ломать бизнес-операцию: всё логируется и проглатывается. + */ +@Service +class AuditClient( + @Value("\${lms.identity.base-url}") baseUrl: String, + @Value("\${lms.internal.token:dev-internal-token}") private val internalToken: String, +) { + private val client = restClient(baseUrl) + + fun record( + actorAccountId: String?, + action: String, + entityType: String, + entityId: String?, + courseId: String? = null, + studentId: String? = null, + details: String = "", + ) { + runCatching { + client + .post() + .uri(AUDIT_PATH) + .header(INTERNAL_TOKEN_HEADER, internalToken) + .contentType(MediaType.APPLICATION_JSON) + .body( + AuditRequest( + actorAccountId = actorAccountId, + action = action, + entityType = entityType, + entityId = entityId, + courseId = courseId, + studentId = studentId, + details = details, + ), + ).retrieve() + .toBodilessEntity() + }.onFailure { ex -> + log.warn("Не удалось записать аудит {}/{}: {}", action, entityType, ex.message) + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + private data class AuditRequest( + val actorAccountId: String?, + val action: String, + val entityType: String, + val entityId: String?, + val courseId: String?, + val studentId: String?, + val details: String, + ) + + private companion object { + val log = LoggerFactory.getLogger(AuditClient::class.java) + } +} + +private const val AUDIT_PATH = "/api/v1/internal/audit" +private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token" +private const val CONNECT_TIMEOUT_SECONDS = 3L +private const val READ_TIMEOUT_SECONDS = 5L + +/** Отдельный клиент с коротким read-timeout: аудит не должен задерживать ответ пользователю. */ +private fun restClient(baseUrl: String): RestClient = + RestClient + .builder() + .baseUrl(baseUrl) + .requestFactory( + JdkClientHttpRequestFactory( + HttpClient + .newBuilder() + .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS)) + .build(), + ).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) }, + ).build() diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt index c1c06a5..eb9e66f 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileController.kt @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.progress.audit.AuditClient import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.util.UUID @@ -18,6 +19,7 @@ import java.util.UUID @RequestMapping("/api/v1/courses") class ProfileController( private val profiles: StudentProfileService, + private val audit: AuditClient, ) { @GetMapping("/{courseId}/students/{studentId}/profile") @PreAuthorize("hasAnyRole('ADMIN','TEACHER','STUDENT')") @@ -25,14 +27,29 @@ class ProfileController( @PathVariable courseId: UUID, @PathVariable studentId: UUID, @AuthenticationPrincipal jwt: Jwt, - ): StudentCourseProfile = - profiles.profile( - courseId = courseId, - studentId = studentId, - callerId = UUID.fromString(jwt.subject), - callerRole = role(jwt), - token = jwt.tokenValue, - ) + ): StudentCourseProfile { + val profile = + profiles.profile( + courseId = courseId, + studentId = studentId, + callerId = UUID.fromString(jwt.subject), + callerRole = role(jwt), + token = jwt.tokenValue, + ) + // Свой профиль студент открывает часто; аудит нужен для Staff-просмотров, + // чтобы не нагружать identity на горячем пути и не засорять журнал. + if (role(jwt) != ROLE_STUDENT) { + audit.record( + actorAccountId = jwt.subject, + action = "PROFILE_VIEW", + entityType = "STUDENT", + entityId = studentId.toString(), + courseId = courseId.toString(), + studentId = studentId.toString(), + ) + } + return profile + } @GetMapping("/{courseId}/submissions/{submissionId}/download") @PreAuthorize("hasAnyRole('ADMIN','TEACHER')") @@ -41,7 +58,7 @@ class ProfileController( @PathVariable submissionId: UUID, @AuthenticationPrincipal jwt: Jwt, ): ResponseEntity { - val (bytes, name) = + val download = profiles.downloadSubmission( courseId = courseId, submissionId = submissionId, @@ -49,11 +66,23 @@ class ProfileController( callerRole = role(jwt), token = jwt.tokenValue, ) - return attachment(bytes, name) + audit.record( + actorAccountId = jwt.subject, + action = "SUBMISSION_DOWNLOAD", + entityType = "SUBMISSION", + entityId = submissionId.toString(), + courseId = courseId.toString(), + studentId = download.studentId.toString(), + ) + return attachment(download.bytes, download.filename) } private fun role(jwt: Jwt): String = jwt.getClaimAsString("role") ?: "" + private companion object { + const val ROLE_STUDENT = "STUDENT" + } + private fun attachment( bytes: ByteArray, name: String, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt index a2489e2..c2fd70a 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/ProfileDtos.kt @@ -1,5 +1,7 @@ package ru.ysu.lms.progress.profile +import java.util.UUID + /** Краткие данные курса в профиле студента. */ data class ProfileCourse( val id: String, @@ -87,6 +89,14 @@ data class ProfileLab( val submissions: List, ) +/** Скачанная сдача вместе с владельцем: нужно для аудита доступа преподавателя. */ +data class SubmissionDownload( + val bytes: ByteArray, + val filename: String, + val studentId: UUID, + val labId: UUID, +) + data class StudentCourseProfile( val course: ProfileCourse, val student: ProfileStudent, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt index 292a627..371e1aa 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/profile/StudentProfileService.kt @@ -118,7 +118,7 @@ class StudentProfileService( callerId: UUID, callerRole: String, token: String, - ): Pair { + ): SubmissionDownload { val role = normalizeRole(callerRole) if (role != ROLE_ADMIN && role != ROLE_TEACHER) { forbidden("Нет доступа к этой сдаче") @@ -141,7 +141,13 @@ class StudentProfileService( forbidden("Студент не состоит в группе курса") } } - return submissionService.download(callerId, submissionId, null, isAdmin = true) + val (bytes, filename) = submissionService.download(callerId, submissionId, null, isAdmin = true) + return SubmissionDownload( + bytes = bytes, + filename = filename, + studentId = submission.studentId, + labId = submission.labId, + ) } private fun requireProfileAccess( diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/ProgressControllerAuditTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/ProgressControllerAuditTest.kt new file mode 100644 index 0000000..01121b4 --- /dev/null +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/ProgressControllerAuditTest.kt @@ -0,0 +1,84 @@ +package ru.ysu.lms.progress + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.springframework.security.oauth2.jwt.Jwt +import ru.ysu.lms.progress.audit.AuditClient +import java.math.BigDecimal +import java.util.UUID + +/** Контроллер пишет аудит только после успешной операции и берёт актора из JWT. */ +class ProgressControllerAuditTest { + private val service = mockk(relaxed = true) + private val grades = mockk(relaxed = true) + private val publications = mockk(relaxed = true) + private val scope = mockk(relaxed = true) + private val audit = mockk(relaxed = true) + private val controller = ProgressController(service, grades, publications, scope, audit) + + private val actorId = UUID.randomUUID() + private val studentId = UUID.randomUUID() + private val labId = UUID.randomUUID() + private val groupId = UUID.randomUUID() + private val jwt = jwt(actorId, ROLE_ADMIN) + + @Test + fun `grade write records audit entry for actor from jwt`() { + every { scope.isAdmin(jwt) } returns true + every { grades.setGrade(any(), actorId) } returns + GradeResponse(studentId.toString(), labId.toString(), BigDecimal("4")) + + controller.setGrade(GradeSetRequest(studentId.toString(), labId.toString(), BigDecimal("4")), jwt) + + verify { + audit.record( + actorId.toString(), + "GRADE_SET", + "GRADE", + "$studentId|$labId", + null, + studentId.toString(), + "score=4", + ) + } + } + + @Test + fun `publication records student count`() { + every { scope.isAdmin(jwt) } returns true + + controller.setPublication( + PublicationSetRequest(groupId.toString(), labId.toString(), listOf(studentId.toString())), + jwt, + ) + + verify { + audit.record( + actorId.toString(), + "GRADE_PUBLISH", + "GRADE_PUBLICATION", + "$groupId|$labId", + null, + null, + "students=1;group=$groupId", + ) + } + } + + private fun jwt( + subject: UUID, + role: String, + ): Jwt = + Jwt + .withTokenValue("token") + .header("alg", "none") + .subject(subject.toString()) + .claim("role", role) + .build() + + private companion object { + const val ROLE_ADMIN = "ADMIN" + } +} diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/ProgressDistributionIntegrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/ProgressDistributionIntegrationTest.kt index 003ab74..e5636e3 100644 --- a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/ProgressDistributionIntegrationTest.kt +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/ProgressDistributionIntegrationTest.kt @@ -36,24 +36,20 @@ class ProgressDistributionIntegrationTest { @MockitoBean private lateinit var academic: AcademicClient - @MockitoBean - private lateinit var identity: IdentityClient - @Test fun `distributes variants to every student of the selected group`() { + val courseId = UUID.randomUUID() val labId = UUID.randomUUID() val variantA = UUID.randomUUID().toString() val variantB = UUID.randomUUID().toString() val student1 = UUID.randomUUID().toString() val student2 = UUID.randomUUID().toString() - `when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1"))) - `when`(identity.getGroup("g1", "t")).thenReturn( - GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))), - ) + stubCourse(courseId, "g1", student1, student2) `when`(academic.getLab(labId.toString(), "t")).thenReturn( LabInfo( id = labId.toString(), + courseId = courseId.toString(), levels = listOf( LevelInfo( @@ -85,18 +81,17 @@ class ProgressDistributionIntegrationTest { @Test fun `group lab data aggregates per-lab data in one call`() { + val courseId = UUID.randomUUID() val labId = UUID.randomUUID() val student1 = UUID.randomUUID().toString() val student2 = UUID.randomUUID().toString() val variantA = UUID.randomUUID().toString() - `when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1"))) - `when`(identity.getGroup("g1", "t")).thenReturn( - GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))), - ) + stubCourse(courseId, "g1", student1, student2) `when`(academic.getLab(labId.toString(), "t")).thenReturn( LabInfo( id = labId.toString(), + courseId = courseId.toString(), levels = listOf( LevelInfo( @@ -121,4 +116,27 @@ class ProgressDistributionIntegrationTest { assertThat(lab.submissions).isEmpty() assertThat(lab.publication.all).isFalse() } + + /** Курс с активной группой и её состав: distribute больше не ходит в identity.listGroups/getGroup. */ + private fun stubCourse( + courseId: UUID, + groupId: String, + vararg studentIds: String, + ) { + `when`(academic.getCourse(courseId.toString(), "t")).thenReturn( + CourseInfo( + id = courseId.toString(), + courseNo = 42, + title = "Алгоритмы", + groups = listOf(CourseGroupInfo(groupId = groupId)), + ), + ) + `when`(academic.getCourseGroupStudents(courseId.toString(), groupId, "t")).thenReturn( + CourseGroupRosterInfo( + groupId = groupId, + groupName = "Группа", + students = studentIds.map { CourseGroupStudentInfo(id = it, fullName = "Студент") }, + ), + ) + } } diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt index 03ff91d..34302ee 100644 --- a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/StudentProfileIntegrationTest.kt @@ -126,10 +126,11 @@ class StudentProfileIntegrationTest { val commentAuthor = lab.comments.single().author assertThat(commentAuthor?.login).isEqualTo("teacher_login") - val (bytes, name) = + val download = profiles.downloadSubmission(courseId, second.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN) - assertThat(name).endsWith(".zip") - assertThat(bytes).isNotEmpty() + assertThat(download.filename).endsWith(".zip") + assertThat(download.bytes).isNotEmpty() + assertThat(download.studentId).isEqualTo(studentId) assertThat(first.id).isNotEqualTo(second.id) } @@ -207,9 +208,9 @@ class StudentProfileIntegrationTest { val adminProfile = profiles.profile(courseId, studentId, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN) assertThat(adminProfile.student.groupId).isEqualTo(otherGroup.toString()) - val (bytes, _) = + val download = profiles.downloadSubmission(courseId, submission.id, UUID.randomUUID(), "ADMIN", ADMIN_TOKEN) - assertThat(bytes).isNotEmpty() + assertThat(download.bytes).isNotEmpty() } @Test @@ -229,10 +230,10 @@ class StudentProfileIntegrationTest { profiles.downloadSubmission(courseId, foreign.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN) }.isInstanceOf(ForbiddenException::class.java) - val (bytes, name) = + val download = profiles.downloadSubmission(courseId, own.id, UUID.randomUUID(), "TEACHER", TEACHER_TOKEN) - assertThat(bytes).isNotEmpty() - assertThat(name).endsWith(".zip") + assertThat(download.bytes).isNotEmpty() + assertThat(download.filename).endsWith(".zip") } private fun stubCourse( diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/TeacherLabPageAccessIntegrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/TeacherLabPageAccessIntegrationTest.kt index e6099c6..fb53dad 100644 --- a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/TeacherLabPageAccessIntegrationTest.kt +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/TeacherLabPageAccessIntegrationTest.kt @@ -4,6 +4,7 @@ import org.assertj.core.api.Assertions.assertThat import org.hamcrest.Matchers.containsString import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -30,6 +31,7 @@ import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.postgresql.PostgreSQLContainer import ru.ysu.lms.common.ForbiddenException +import ru.ysu.lms.progress.audit.AuditClient import ru.ysu.lms.progress.domain.Submission import ru.ysu.lms.progress.repo.StudentLabLevelRepository import ru.ysu.lms.progress.repo.SubmissionRepository @@ -80,8 +82,17 @@ class TeacherLabPageAccessIntegrationTest { @MockitoBean private lateinit var academic: AcademicClient + @MockitoBean + private lateinit var identity: IdentityClient + + @MockitoBean + private lateinit var audit: AuditClient + private val ownLab = UUID.randomUUID() + private val teacherId = UUID.randomUUID() private val foreignLab = UUID.randomUUID() + private val courseId = UUID.randomUUID() + private val groupId = UUID.randomUUID() private val levelId = UUID.randomUUID() private val variantId = UUID.randomUUID() @@ -96,6 +107,7 @@ class TeacherLabPageAccessIntegrationTest { LabInfo( id = id.toString(), title = "Лабораторная", + courseId = courseId.toString(), levels = listOf( LevelInfo( @@ -119,17 +131,39 @@ class TeacherLabPageAccessIntegrationTest { .thenThrow(ForbiddenException("Лабораторная недоступна")) } - private fun teacher() = jwtFor("TEACHER") + /** Преподаватель пишет только студентам активной группы своего курса. */ + private fun mockStudentInCourse(studentId: UUID) { + `when`(identity.getStudentContext(studentId.toString())).thenReturn( + StudentContext( + id = studentId.toString(), + fullName = "Иванов Иван", + status = "active", + groupId = groupId.toString(), + ), + ) + `when`(academic.getCourse(courseId.toString(), "token")).thenReturn( + CourseInfo( + id = courseId.toString(), + courseNo = 42, + title = "Алгоритмы", + groups = listOf(CourseGroupInfo(groupId = groupId.toString())), + ), + ) + } + + private fun teacher() = jwtFor("TEACHER", teacherId) private fun admin() = jwtFor("ADMIN") - private fun jwtFor(role: String) = - jwt() - .jwt { - it - .subject(UUID.randomUUID().toString()) - .claim("role", role) - }.authorities(SimpleGrantedAuthority("ROLE_$role")) + private fun jwtFor( + role: String, + subject: UUID = UUID.randomUUID(), + ) = jwt() + .jwt { + it + .subject(subject.toString()) + .claim("role", role) + }.authorities(SimpleGrantedAuthority("ROLE_$role")) private fun getLevels( labId: UUID, @@ -198,6 +232,7 @@ class TeacherLabPageAccessIntegrationTest { fun `teacher sets level and assigns variant of own lab`() { mockOwnLab() val studentId = UUID.randomUUID() + mockStudentInCourse(studentId) mockMvc.perform(postLevel(ownLab, "TEACHER", studentId)).andExpect(status().isOk) @@ -223,6 +258,16 @@ class TeacherLabPageAccessIntegrationTest { ).andExpect(status().isOk) .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("attachment"))) .andExpect(content().contentType(MediaType.APPLICATION_OCTET_STREAM)) + + verify(audit).record( + teacherId.toString(), + "SUBMISSION_DOWNLOAD", + "SUBMISSION", + submission.id.toString(), + null, + null, + "staff", + ) } @Test diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/TeacherWriteAccessIntegrationTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/TeacherWriteAccessIntegrationTest.kt new file mode 100644 index 0000000..c03a681 --- /dev/null +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/TeacherWriteAccessIntegrationTest.kt @@ -0,0 +1,478 @@ +package ru.ysu.lms.progress + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.postgresql.PostgreSQLContainer +import ru.ysu.lms.common.ForbiddenException +import ru.ysu.lms.progress.audit.AuditClient +import ru.ysu.lms.progress.repo.VariantAssignmentRepository +import java.math.BigDecimal +import java.util.UUID + +/** + * Запись преподавателем только в своём курсе: оценки, комментарии, публикации, + * защиты, распределение вариантов и данные группы. Чужой курс — 403, студент — 403. + * Academic/identity подменены так, чтобы вести себя как реальные сервисы. + */ +@Testcontainers +@SpringBootTest +@Transactional +class TeacherWriteAccessIntegrationTest { + companion object { + @Container + @ServiceConnection + @JvmStatic + val postgres = PostgreSQLContainer("postgres:18") + } + + @Autowired + private lateinit var context: WebApplicationContext + + @Autowired + private lateinit var grades: GradeService + + @Autowired + private lateinit var assignments: VariantAssignmentRepository + + @MockitoBean + private lateinit var academic: AcademicClient + + @MockitoBean + private lateinit var identity: IdentityClient + + @MockitoBean + private lateinit var audit: AuditClient + + private val teacherId = UUID.randomUUID() + private val courseId = UUID.randomUUID() + private val otherCourseId = UUID.randomUUID() + private val groupId = UUID.randomUUID() + private val otherGroupId = UUID.randomUUID() + private val ownLab = UUID.randomUUID() + private val foreignLab = UUID.randomUUID() + private val otherCourseLab = UUID.randomUUID() + private val studentId = UUID.randomUUID() + private val otherStudentId = UUID.randomUUID() + private val levelId = UUID.randomUUID() + private val variantId = UUID.randomUUID() + + private val mockMvc: MockMvc by lazy { + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + private fun teacher() = jwtFor("TEACHER", teacherId) + + private fun admin() = jwtFor("ADMIN") + + private fun student() = jwtFor("STUDENT", studentId) + + private fun jwtFor( + role: String, + subject: UUID = UUID.randomUUID(), + ) = jwt() + .jwt { + it + .subject(subject.toString()) + .claim("role", role) + }.authorities(SimpleGrantedAuthority("ROLE_$role")) + + private fun course( + id: UUID, + vararg groupIds: UUID, + ) = CourseInfo( + id = id.toString(), + courseNo = 42, + title = "Алгоритмы", + groups = groupIds.map { CourseGroupInfo(groupId = it.toString()) }, + ) + + private fun roster( + groupId: UUID, + vararg studentIds: UUID, + ) = CourseGroupRosterInfo( + groupId = groupId.toString(), + groupName = "Группа", + students = studentIds.map { CourseGroupStudentInfo(id = it.toString(), fullName = "Студент", status = "active") }, + ) + + private fun studentContext( + studentId: UUID, + groupId: UUID?, + ) = StudentContext( + id = studentId.toString(), + login = "student", + fullName = "Иванов Иван", + studentNo = "2024-001", + status = "active", + groupId = groupId?.toString(), + groupName = "Группа", + ) + + private fun labInfo( + labId: UUID, + courseId: UUID, + ) = LabInfo( + id = labId.toString(), + number = 1, + title = "Лабораторная", + courseId = courseId.toString(), + levels = + listOf( + LevelInfo( + id = levelId.toString(), + code = "basic", + title = "Основная", + variants = listOf(VariantInfo(id = variantId.toString(), number = "1")), + ), + ), + ) + + private fun stubOwnCourse() { + `when`(academic.getCourse(courseId.toString(), "token")).thenReturn(course(courseId, groupId)) + `when`(academic.getCourseLabs(courseId.toString(), "token")) + .thenReturn(listOf(labInfo(ownLab, courseId))) + `when`(academic.getCourseGroupStudents(courseId.toString(), groupId.toString(), "token")) + .thenReturn(roster(groupId, studentId)) + } + + private fun stubOwnLab() { + `when`(academic.getLab(ownLab.toString(), "token")).thenReturn(labInfo(ownLab, courseId)) + `when`(academic.getLabSummary(ownLab.toString(), "token")).thenReturn(labInfo(ownLab, courseId)) + } + + private fun stubForeignLab() { + `when`(academic.getLab(foreignLab.toString(), "token")) + .thenThrow(ForbiddenException("Лабораторная недоступна")) + `when`(academic.getLabSummary(foreignLab.toString(), "token")) + .thenThrow(ForbiddenException("Лабораторная недоступна")) + } + + private fun stubOwnStudent() { + `when`(identity.getStudentContext(studentId.toString())).thenReturn(studentContext(studentId, groupId)) + } + + private fun stubOtherCourseStudent() { + `when`(identity.getStudentContext(otherStudentId.toString())) + .thenReturn(studentContext(otherStudentId, otherGroupId)) + } + + private fun gradeBody( + studentId: UUID, + labId: UUID, + score: String = "4", + ) = """ + {"studentId":"$studentId","labId":"$labId","score":$score, + "comments":[{"text":"Хорошо","tone":"positive"}],"generalComment":"Зачтено"} + """.trimIndent() + + private fun publicationBody( + groupId: UUID, + labId: UUID, + studentIds: List, + ) = """ + {"groupId":"$groupId","labId":"$labId","studentIds":[${studentIds.joinToString(",") { "\"$it\"" }}]} + """.trimIndent() + + @Test + fun `teacher writes grade for student of own course`() { + stubOwnCourse() + stubOwnLab() + stubOwnStudent() + + mockMvc + .perform( + post("/api/v1/grades") + .contentType(MediaType.APPLICATION_JSON) + .content(gradeBody(studentId, ownLab)) + .with(teacher()), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.score").value(4)) + + verify(audit).record( + teacherId.toString(), + "GRADE_SET", + "GRADE", + "$studentId|$ownLab", + null, + studentId.toString(), + "score=4", + ) + } + + @Test + fun `teacher is forbidden to grade foreign lab`() { + stubForeignLab() + + mockMvc + .perform( + post("/api/v1/grades") + .contentType(MediaType.APPLICATION_JSON) + .content(gradeBody(studentId, foreignLab)) + .with(teacher()), + ).andExpect(status().isForbidden) + } + + @Test + fun `teacher is forbidden to grade student from another course`() { + stubOwnCourse() + stubOwnLab() + stubOtherCourseStudent() + + mockMvc + .perform( + post("/api/v1/grades") + .contentType(MediaType.APPLICATION_JSON) + .content(gradeBody(otherStudentId, ownLab)) + .with(teacher()), + ).andExpect(status().isForbidden) + } + + @Test + fun `teacher lists grades only for own course and must pass courseId`() { + stubOwnCourse() + grades.setGrade(GradeSetRequest(studentId.toString(), ownLab.toString(), BigDecimal("4"))) + grades.setGrade(GradeSetRequest(studentId.toString(), foreignLab.toString(), BigDecimal("5"))) + + mockMvc + .perform(get("/api/v1/grades").param("courseId", courseId.toString()).with(teacher())) + .andExpect(status().isOk) + .andExpect(jsonPath("$[0].labId").value(ownLab.toString())) + .andExpect(jsonPath("$.length()").value(1)) + + mockMvc + .perform(get("/api/v1/grades").with(teacher())) + .andExpect(status().isBadRequest) + } + + @Test + fun `teacher manages publication of own course and is forbidden for foreign lab`() { + stubOwnCourse() + stubOwnLab() + stubForeignLab() + + mockMvc + .perform( + get("/api/v1/grades/publication") + .param("groupId", groupId.toString()) + .param("labId", ownLab.toString()) + .with(teacher()), + ).andExpect(status().isOk) + + mockMvc + .perform( + post("/api/v1/grades/publication") + .contentType(MediaType.APPLICATION_JSON) + .content(publicationBody(groupId, ownLab, listOf(studentId))) + .with(teacher()), + ).andExpect(status().isOk) + + mockMvc + .perform( + get("/api/v1/grades/publication") + .param("groupId", groupId.toString()) + .param("labId", foreignLab.toString()) + .with(teacher()), + ).andExpect(status().isForbidden) + } + + @Test + fun `teacher assigns and lists defenses of own lab but not foreign`() { + stubOwnCourse() + stubOwnLab() + stubOwnStudent() + stubForeignLab() + + mockMvc + .perform( + post("/api/v1/defenses/assign") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"studentId":"$studentId","labId":"$ownLab","assignmentMd":"Задание"}""") + .with(teacher()), + ).andExpect(status().isOk) + + mockMvc + .perform(get("/api/v1/defenses").param("labId", ownLab.toString()).with(teacher())) + .andExpect(status().isOk) + + mockMvc + .perform( + post("/api/v1/defenses/assign") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"studentId":"$studentId","labId":"$foreignLab","assignmentMd":"Задание"}""") + .with(teacher()), + ).andExpect(status().isForbidden) + + mockMvc + .perform(get("/api/v1/defenses").param("labId", foreignLab.toString()).with(teacher())) + .andExpect(status().isForbidden) + } + + @Test + fun `teacher distributes variants within own course`() { + stubOwnCourse() + `when`(academic.getLab(ownLab.toString(), "token")).thenReturn(labInfo(ownLab, courseId)) + + mockMvc + .perform( + post("/api/v1/distribute") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"labIds":["$ownLab"],"groupIds":["$groupId"]}""") + .with(teacher()), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.assigned").value(1)) + + assertThat(assignments.findByLabId(ownLab).map { it.studentId }).containsExactly(studentId) + } + + @Test + fun `teacher cannot distribute to foreign or mixed course labs`() { + stubForeignLab() + mockMvc + .perform( + post("/api/v1/distribute") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"labIds":["$foreignLab"]}""") + .with(teacher()), + ).andExpect(status().isForbidden) + + `when`(academic.getLab(ownLab.toString(), "token")).thenReturn(labInfo(ownLab, courseId)) + `when`(academic.getLab(otherCourseLab.toString(), "token")) + .thenReturn(labInfo(otherCourseLab, otherCourseId)) + mockMvc + .perform( + post("/api/v1/distribute") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"labIds":["$ownLab","$otherCourseLab"]}""") + .with(teacher()), + ).andExpect(status().isBadRequest) + } + + @Test + fun `teacher reads group lab data of own course only`() { + stubOwnCourse() + stubOwnLab() + + mockMvc + .perform( + get("/api/v1/assignments/group/{groupId}", groupId) + .param("labId", ownLab.toString()) + .with(teacher()), + ).andExpect(status().isOk) + + mockMvc + .perform( + get("/api/v1/assignments/group/{groupId}", otherGroupId) + .param("labId", ownLab.toString()) + .with(teacher()), + ).andExpect(status().isForbidden) + + stubForeignLab() + mockMvc + .perform( + get("/api/v1/assignments/group/{groupId}", groupId) + .param("labId", foreignLab.toString()) + .with(teacher()), + ).andExpect(status().isForbidden) + } + + @Test + fun `student is forbidden on write endpoints`() { + mockMvc + .perform( + post("/api/v1/grades") + .contentType(MediaType.APPLICATION_JSON) + .content(gradeBody(studentId, ownLab)) + .with(student()), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/grades/publication") + .contentType(MediaType.APPLICATION_JSON) + .content(publicationBody(groupId, ownLab, listOf(studentId))) + .with(student()), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/distribute") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"labIds":["$ownLab"]}""") + .with(student()), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/defenses/assign") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"studentId":"$studentId","labId":"$ownLab","assignmentMd":"Задание"}""") + .with(student()), + ).andExpect(status().isForbidden) + + mockMvc + .perform(get("/api/v1/defenses").param("labId", ownLab.toString()).with(student())) + .andExpect(status().isForbidden) + + mockMvc + .perform( + post("/api/v1/levels") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"studentId":"$studentId","labId":"$ownLab","labLevelId":"$levelId"}""") + .with(student()), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + get("/api/v1/assignments/group/{groupId}", groupId) + .param("labId", ownLab.toString()) + .with(student()), + ).andExpect(status().isForbidden) + + mockMvc + .perform( + delete("/api/v1/comment-templates") + .param("text", "Хорошо") + .with(student()), + ).andExpect(status().isForbidden) + } + + @Test + fun `admin keeps access without course scope checks`() { + mockMvc + .perform( + post("/api/v1/grades") + .contentType(MediaType.APPLICATION_JSON) + .content(gradeBody(studentId, foreignLab)) + .with(admin()), + ).andExpect(status().isOk) + + mockMvc + .perform(get("/api/v1/grades").with(admin())) + .andExpect(status().isOk) + } +} diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/audit/AuditClientTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/audit/AuditClientTest.kt new file mode 100644 index 0000000..6ce8d22 --- /dev/null +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/audit/AuditClientTest.kt @@ -0,0 +1,88 @@ +package ru.ysu.lms.progress.audit + +import com.sun.net.httpserver.HttpServer +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList + +/** Аудит — best-effort: тело запроса уходит в identity, любые сбои проглатываются. */ +class AuditClientTest { + private lateinit var server: HttpServer + private val bodies = CopyOnWriteArrayList() + private val tokens = CopyOnWriteArrayList() + + @BeforeEach + fun startServer() { + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext(AUDIT_PATH) { exchange -> + tokens += exchange.requestHeaders.getFirst(INTERNAL_TOKEN_HEADER).orEmpty() + bodies += exchange.requestBody.use { it.readBytes().toString(Charsets.UTF_8) } + exchange.sendResponseHeaders(204, -1) + exchange.close() + } + server.start() + } + + @AfterEach + fun stopServer() { + server.stop(0) + } + + @Test + fun `record sends actor action and entity to identity`() { + client().record( + actorAccountId = ACTOR_ID, + action = "GRADE_SET", + entityType = "GRADE", + entityId = "$STUDENT_ID|$LAB_ID", + courseId = COURSE_ID, + studentId = STUDENT_ID, + details = "score=4", + ) + + assertThat(tokens).containsExactly(INTERNAL_TOKEN) + assertThat(bodies).hasSize(1) + assertThat(bodies.single()) + .contains("\"actorAccountId\":\"$ACTOR_ID\"") + .contains("\"action\":\"GRADE_SET\"") + .contains("\"entityType\":\"GRADE\"") + .contains("\"entityId\":\"$STUDENT_ID|$LAB_ID\"") + .contains("\"courseId\":\"$COURSE_ID\"") + .contains("\"studentId\":\"$STUDENT_ID\"") + .contains("\"details\":\"score=4\"") + } + + @Test + fun `record swallows unreachable identity`() { + val client = AuditClient(baseUrl = "http://127.0.0.1:1", internalToken = INTERNAL_TOKEN) + + assertThatCode { + client.record( + actorAccountId = ACTOR_ID, + action = "PROFILE_VIEW", + entityType = "STUDENT", + entityId = STUDENT_ID, + ) + }.doesNotThrowAnyException() + } + + private fun client(): AuditClient = + AuditClient( + baseUrl = "http://127.0.0.1:${server.address.port}", + internalToken = INTERNAL_TOKEN, + ) + + private companion object { + const val AUDIT_PATH = "/api/v1/internal/audit" + const val INTERNAL_TOKEN_HEADER = "X-Internal-Token" + const val INTERNAL_TOKEN = "test-internal-token" + const val ACTOR_ID = "11111111-1111-1111-1111-111111111111" + const val STUDENT_ID = "22222222-2222-2222-2222-222222222222" + const val LAB_ID = "33333333-3333-3333-3333-333333333333" + const val COURSE_ID = "44444444-4444-4444-4444-444444444444" + } +}