diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditActor.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditActor.kt new file mode 100644 index 0000000..b7ce95e --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditActor.kt @@ -0,0 +1,26 @@ +package ru.ysu.lms.identity.audit + +import org.springframework.security.oauth2.jwt.Jwt + +/** + * Собирает событие аудита из JWT администратора. Subject — идентификатор + * аккаунта, claim `login` — логин (см. [ru.ysu.lms.identity.auth.AuthService]). + */ +fun Jwt.toAuditRequest( + action: String, + entityType: String, + entityId: String? = null, + courseId: String? = null, + studentId: String? = null, + details: String = "", +): AuditEventRequest = + AuditEventRequest( + actorAccountId = subject, + actorLogin = getClaimAsString("login"), + action = action, + entityType = entityType, + entityId = entityId, + courseId = courseId, + studentId = studentId, + details = details, + ) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt new file mode 100644 index 0000000..3be348a --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt @@ -0,0 +1,21 @@ +package ru.ysu.lms.identity.audit + +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +/** Админская лента центрального журнала аудита. */ +@RestController +@RequestMapping("/api/v1/audit") +@PreAuthorize("hasRole('ADMIN')") +class AuditController( + private val auditService: AuditService, +) { + @GetMapping + fun list( + @RequestParam(defaultValue = "100") limit: Int, + @RequestParam(required = false) action: String?, + ): List = auditService.list(limit, action) +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditDtos.kt new file mode 100644 index 0000000..d7a2d39 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditDtos.kt @@ -0,0 +1,27 @@ +package ru.ysu.lms.identity.audit + +/** Событие аудита от внутреннего сервиса или локального хука identity. */ +data class AuditEventRequest( + val actorAccountId: String? = null, + val actorLogin: String? = null, + val action: String, + val entityType: String, + val entityId: String? = null, + val courseId: String? = null, + val studentId: String? = null, + val details: String = "", +) + +/** Представление события для админской ленты. */ +data class AuditEventDto( + val id: String, + val occurredAt: String, + val actorAccountId: String?, + val actorLogin: String?, + val action: String, + val entityType: String, + val entityId: String?, + val courseId: String?, + val studentId: String?, + val details: String, +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEvent.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEvent.kt new file mode 100644 index 0000000..8a07012 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEvent.kt @@ -0,0 +1,38 @@ +package ru.ysu.lms.identity.audit + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import ru.ysu.lms.common.Ids +import java.time.Instant +import java.util.UUID + +/** + * Запись центрального журнала аудита. Внешних ключей намеренно нет: событие + * остаётся в журнале после удаления аккаунта, студента или группы. + */ +@Entity +@Table(name = "audit_events") +class AuditEvent( + @Id + var id: UUID = Ids.v7(), + @Column(name = "occurred_at", nullable = false) + var occurredAt: Instant = Instant.now(), + @Column(name = "actor_account_id") + var actorAccountId: UUID? = null, + @Column(name = "actor_login", length = 100) + var actorLogin: String? = null, + @Column(nullable = false, length = 64) + var action: String = "", + @Column(name = "entity_type", nullable = false, length = 64) + var entityType: String = "", + @Column(name = "entity_id", length = 128) + var entityId: String? = null, + @Column(name = "course_id") + var courseId: UUID? = null, + @Column(name = "student_id") + var studentId: UUID? = null, + @Column(nullable = false) + var details: String = "", +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEventRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEventRepository.kt new file mode 100644 index 0000000..77c5e02 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEventRepository.kt @@ -0,0 +1,16 @@ +package ru.ysu.lms.identity.audit + +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface AuditEventRepository : JpaRepository { + fun findTop200ByOrderByOccurredAtDesc(): List + + fun findAllByOrderByOccurredAtDescIdDesc(pageable: Pageable): List + + fun findAllByActionOrderByOccurredAtDescIdDesc( + action: String, + pageable: Pageable, + ): List +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt new file mode 100644 index 0000000..b9ea6b7 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt @@ -0,0 +1,99 @@ +package ru.ysu.lms.identity.audit + +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.ysu.lms.common.BadRequestException +import java.util.UUID + +/** + * Центральный журнал аудита identity. + * + * Обязательные поля (`action`, `entityType`) валидируются, а необязательные + * нормализуются максимально мягко: битый UUID превращается в `null`, длинные + * строки обрезаются под размер колонок. Благодаря этому локальные хуки + * бизнес-операций не падают из-за второстепенных полей события. + */ +@Service +class AuditService( + private val events: AuditEventRepository, +) { + @Transactional + fun record(request: AuditEventRequest): UUID { + val event = + AuditEvent( + actorAccountId = parseUuid(request.actorAccountId), + actorLogin = clampOptional(request.actorLogin, ACTOR_LOGIN_MAX), + action = requireText(request.action, "action", ACTION_MAX), + entityType = requireText(request.entityType, "entityType", ENTITY_TYPE_MAX), + entityId = clampOptional(request.entityId, ENTITY_ID_MAX), + courseId = parseUuid(request.courseId), + studentId = parseUuid(request.studentId), + details = clampOptional(request.details, DETAILS_MAX).orEmpty(), + ) + return events.save(event).id + } + + @Transactional(readOnly = true) + fun list( + limit: Int = DEFAULT_LIMIT, + action: String? = null, + ): List { + val size = limit.coerceIn(1, MAX_LIMIT) + val page = PageRequest.of(0, size) + val filter = action?.trim()?.takeIf { it.isNotEmpty() } + val found = + if (filter == null) { + events.findAllByOrderByOccurredAtDescIdDesc(page) + } else { + events.findAllByActionOrderByOccurredAtDescIdDesc(filter, page) + } + return found.map(::toDto) + } + + private fun toDto(event: AuditEvent): AuditEventDto = + AuditEventDto( + id = event.id.toString(), + occurredAt = event.occurredAt.toString(), + actorAccountId = event.actorAccountId?.toString(), + actorLogin = event.actorLogin, + action = event.action, + entityType = event.entityType, + entityId = event.entityId, + courseId = event.courseId?.toString(), + studentId = event.studentId?.toString(), + details = event.details, + ) + + private fun requireText( + raw: String, + field: String, + maxLength: Int, + ): String { + val value = raw.trim() + if (value.isEmpty()) throw BadRequestException("Поле $field обязательно и не может быть пустым") + return value.take(maxLength) + } + + private fun clampOptional( + raw: String?, + maxLength: Int, + ): String? = raw?.trim()?.takeIf { it.isNotEmpty() }?.take(maxLength) + + /** Необязательный идентификатор: некорректный UUID не должен ломать операцию. */ + private fun parseUuid(raw: String?): UUID? = + raw + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { runCatching { UUID.fromString(it) }.getOrNull() } + + private companion object { + const val DEFAULT_LIMIT = 100 + const val MAX_LIMIT = 500 + const val ACTION_MAX = 64 + const val ENTITY_TYPE_MAX = 64 + const val ENTITY_ID_MAX = 128 + const val ACTOR_LOGIN_MAX = 100 + const val DETAILS_MAX = 2000 + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalController.kt index 9e296fb..a4466ee 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalController.kt @@ -1,6 +1,7 @@ package ru.ysu.lms.identity.internal import org.springframework.beans.factory.annotation.Value +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -9,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import ru.ysu.lms.common.ForbiddenException +import ru.ysu.lms.identity.audit.AuditEventRequest +import ru.ysu.lms.identity.audit.AuditService import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.util.UUID @@ -21,6 +24,7 @@ import java.util.UUID @RequestMapping("/api/v1/internal") class InternalController( private val service: InternalService, + private val auditService: AuditService, @Value("\${lms.internal.token}") private val internalToken: String, ) { @GetMapping("/students/{studentId}/context") @@ -41,6 +45,14 @@ class InternalController( return service.lookupAccounts(request.ids) } + @GetMapping("/groups") + fun groups( + @RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?, + ): List { + ensureToken(token) + return service.groups() + } + @GetMapping("/groups/{groupId}") fun group( @RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?, @@ -50,6 +62,17 @@ class InternalController( return service.group(groupId) } + /** Запись в центральный журнал аудита от academic/progress и других сервисов. */ + @PostMapping("/audit") + fun audit( + @RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?, + @RequestBody request: AuditEventRequest, + ): ResponseEntity { + ensureToken(token) + auditService.record(request) + return ResponseEntity.noContent().build() + } + private fun ensureToken(token: String?) { if (token == null || !MessageDigest.isEqual(token.toByteArray(StandardCharsets.UTF_8), internalToken.toByteArray(StandardCharsets.UTF_8)) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt index a88e898..8d5b55a 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalDtos.kt @@ -30,6 +30,13 @@ data class InternalGroupDto( val students: List, ) +/** Краткая карточка группы для управления курсами преподавателя. */ +data class InternalGroupSummaryDto( + val id: String, + val name: String, + val status: String, +) + data class InternalGroupStudentDto( val id: String, val studentNo: String, diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt index 2d9dd37..4271d2f 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/internal/InternalService.kt @@ -62,6 +62,20 @@ class InternalService( return InternalGroupDto(id = group.id.toString(), name = group.name, students = members) } + /** Все группы без состава и учётных данных, отсортированные по названию и id. */ + @Transactional(readOnly = true) + fun groups(): List = + groups + .findAll() + .sortedWith(compareBy({ it.name }, { it.id })) + .map { group -> + InternalGroupSummaryDto( + id = group.id.toString(), + name = group.name, + status = group.status.name, + ) + } + /** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */ @Transactional(readOnly = true) fun lookupAccounts(ids: List): List { diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionController.kt index 459a222..8e56e1d 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/promotion/PromotionController.kt @@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.identity.audit.AuditService +import ru.ysu.lms.identity.audit.toAuditRequest import java.util.UUID @RestController @@ -16,6 +18,7 @@ import java.util.UUID @PreAuthorize("hasRole('ADMIN')") class PromotionController( private val promotionService: PromotionService, + private val auditService: AuditService, ) { @GetMapping fun list(): List = promotionService.list() @@ -29,23 +32,54 @@ class PromotionController( fun dryRun( @RequestBody(required = false) body: DryRunRequest?, @AuthenticationPrincipal jwt: Jwt, - ): PromotionRunDetail = promotionService.dryRun(body?.year, UUID.fromString(jwt.subject)) + ): PromotionRunDetail { + val detail = promotionService.dryRun(body?.year, UUID.fromString(jwt.subject)) + audit(jwt, "PROMOTION_DRY_RUN", detail) + return detail + } @PostMapping("/{id}/apply") fun apply( @PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt, - ): PromotionRunDetail = promotionService.apply(id, UUID.fromString(jwt.subject)) + ): PromotionRunDetail { + val detail = promotionService.apply(id, UUID.fromString(jwt.subject)) + audit(jwt, "PROMOTION_APPLY", detail) + return detail + } @PostMapping("/{id}/cancel") fun cancel( @PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt, - ): PromotionRunDetail = promotionService.cancel(id, UUID.fromString(jwt.subject)) + ): PromotionRunDetail { + val detail = promotionService.cancel(id, UUID.fromString(jwt.subject)) + audit(jwt, "PROMOTION_CANCEL", detail) + return detail + } @PostMapping("/{id}/rollback") fun rollback( @PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt, - ): PromotionRunDetail = promotionService.rollback(id, UUID.fromString(jwt.subject)) + ): PromotionRunDetail { + val detail = promotionService.rollback(id, UUID.fromString(jwt.subject)) + audit(jwt, "PROMOTION_ROLLBACK", detail) + return detail + } + + private fun audit( + jwt: Jwt, + action: String, + detail: PromotionRunDetail, + ) { + auditService.record( + jwt.toAuditRequest( + action = action, + entityType = "PROMOTION_RUN", + entityId = detail.id, + details = "year=${detail.year}; status=${detail.status}", + ), + ) + } } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt index 40f7da4..fd7a3ca 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/GroupController.kt @@ -17,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile import ru.ysu.lms.common.BadRequestException +import ru.ysu.lms.identity.audit.AuditService +import ru.ysu.lms.identity.audit.toAuditRequest import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.util.UUID @@ -27,6 +29,7 @@ import java.util.UUID class GroupController( private val groupService: GroupService, private val importService: GroupImportService, + private val auditService: AuditService, ) { @GetMapping fun list(): List = groupService.list() @@ -91,7 +94,19 @@ class GroupController( @PostMapping("/{id}/archive") fun archive( @PathVariable id: UUID, - ): GroupDetail = groupService.archiveGroup(id) + @AuthenticationPrincipal jwt: Jwt, + ): GroupDetail { + val detail = groupService.archiveGroup(id) + auditService.record( + jwt.toAuditRequest( + action = "GROUP_ARCHIVE", + entityType = "GROUP", + entityId = id.toString(), + details = "name=${detail.name}", + ), + ) + return detail + } @GetMapping("/{id}/credentials") fun credentials( diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt index 2eab022..e91fb49 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentController.kt @@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.PostMapping 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.identity.audit.AuditService +import ru.ysu.lms.identity.audit.toAuditRequest import java.util.UUID @RestController @@ -17,6 +19,7 @@ import java.util.UUID @PreAuthorize("hasRole('ADMIN')") class StudentController( private val groupService: GroupService, + private val auditService: AuditService, ) { @PostMapping("/{id}/reset-password") fun resetPassword( @@ -35,5 +38,17 @@ class StudentController( @PathVariable id: UUID, @RequestParam("confirm") confirm: String, @AuthenticationPrincipal jwt: Jwt, - ): DeleteUserResult = groupService.deleteUser(id, UUID.fromString(jwt.subject), confirm) + ): DeleteUserResult { + val result = groupService.deleteUser(id, UUID.fromString(jwt.subject), confirm) + auditService.record( + jwt.toAuditRequest( + action = "STUDENT_DELETE", + entityType = "STUDENT", + entityId = id.toString(), + studentId = id.toString(), + details = "login=${result.login}; fullName=${result.fullName}", + ), + ) + return result + } } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleController.kt index a0e27af..9e48af0 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/StudentLifecycleController.kt @@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.identity.audit.AuditService +import ru.ysu.lms.identity.audit.toAuditRequest import java.util.UUID @RestController @@ -16,40 +18,86 @@ import java.util.UUID @PreAuthorize("hasRole('ADMIN')") class StudentLifecycleController( private val lifecycle: StudentLifecycleService, + private val auditService: AuditService, ) { @PostMapping("/{id}/academic-leave") fun academicLeave( @PathVariable id: UUID, @RequestBody(required = false) body: StudentReasonRequest?, @AuthenticationPrincipal jwt: Jwt, - ): StudentLifecycleResult = lifecycle.academicLeave(id, body, actor(jwt)) + ): StudentLifecycleResult { + val result = lifecycle.academicLeave(id, body, actor(jwt)) + audit(jwt, "STUDENT_ACADEMIC_LEAVE", id, result, body?.reason) + return result + } @PostMapping("/{id}/return") fun returnFromLeave( @PathVariable id: UUID, @Valid @RequestBody body: StudentGroupRequest, @AuthenticationPrincipal jwt: Jwt, - ): StudentLifecycleResult = lifecycle.returnFromLeave(id, body, actor(jwt)) + ): StudentLifecycleResult { + val result = lifecycle.returnFromLeave(id, body, actor(jwt)) + audit(jwt, "STUDENT_RETURN", id, result, body.reason) + return result + } @PostMapping("/{id}/expel") fun expel( @PathVariable id: UUID, @RequestBody(required = false) body: StudentReasonRequest?, @AuthenticationPrincipal jwt: Jwt, - ): StudentLifecycleResult = lifecycle.expel(id, body, actor(jwt)) + ): StudentLifecycleResult { + val result = lifecycle.expel(id, body, actor(jwt)) + audit(jwt, "STUDENT_EXPEL", id, result, body?.reason) + return result + } @PostMapping("/{id}/archive") fun archive( @PathVariable id: UUID, @AuthenticationPrincipal jwt: Jwt, - ): StudentLifecycleResult = lifecycle.archive(id, actor(jwt)) + ): StudentLifecycleResult { + val result = lifecycle.archive(id, actor(jwt)) + audit(jwt, "STUDENT_ARCHIVE", id, result, null) + return result + } @PostMapping("/{id}/restore") fun restore( @PathVariable id: UUID, @Valid @RequestBody body: StudentGroupRequest, @AuthenticationPrincipal jwt: Jwt, - ): StudentLifecycleResult = lifecycle.restore(id, body, actor(jwt)) + ): StudentLifecycleResult { + val result = lifecycle.restore(id, body, actor(jwt)) + audit(jwt, "STUDENT_RESTORE", id, result, body.reason) + return result + } private fun actor(jwt: Jwt): UUID = UUID.fromString(jwt.subject) + + private fun audit( + jwt: Jwt, + action: String, + studentId: UUID, + result: StudentLifecycleResult, + reason: String?, + ) { + val details = + buildList { + reason?.trim()?.takeIf { it.isNotEmpty() }?.let { add("reason=$it") } + result.fromGroupId?.let { add("fromGroupId=$it") } + result.toGroupId?.let { add("toGroupId=$it") } + add("status=${result.status}") + }.joinToString("; ") + auditService.record( + jwt.toAuditRequest( + action = action, + entityType = "STUDENT", + entityId = studentId.toString(), + studentId = studentId.toString(), + details = details, + ), + ) + } } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherController.kt index 59227a5..b9e454f 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/roster/TeacherController.kt @@ -1,12 +1,16 @@ package ru.ysu.lms.identity.roster 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.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.identity.audit.AuditService +import ru.ysu.lms.identity.audit.toAuditRequest import java.util.UUID @RestController @@ -14,6 +18,7 @@ import java.util.UUID @PreAuthorize("hasRole('ADMIN')") class TeacherController( private val teacherService: TeacherService, + private val auditService: AuditService, ) { @GetMapping fun list(): List = teacherService.list() @@ -21,10 +26,33 @@ class TeacherController( @PostMapping fun create( @RequestBody body: TeacherCreateRequest, - ): TeacherCredential = teacherService.create(body) + @AuthenticationPrincipal jwt: Jwt, + ): TeacherCredential { + val credential = teacherService.create(body) + auditService.record( + jwt.toAuditRequest( + action = "TEACHER_CREATE", + entityType = "TEACHER", + entityId = credential.id, + details = "login=${credential.login}", + ), + ) + return credential + } @PostMapping("/{id}/reset-password") fun resetPassword( @PathVariable id: UUID, - ): ResetPasswordResponse = teacherService.resetPassword(id) + @AuthenticationPrincipal jwt: Jwt, + ): ResetPasswordResponse { + val response = teacherService.resetPassword(id) + auditService.record( + jwt.toAuditRequest( + action = "TEACHER_PASSWORD_RESET", + entityType = "TEACHER", + entityId = id.toString(), + ), + ) + return response + } } diff --git a/services/identity-service/src/main/resources/db/migration/V10__audit_events.sql b/services/identity-service/src/main/resources/db/migration/V10__audit_events.sql new file mode 100644 index 0000000..bb5f251 --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V10__audit_events.sql @@ -0,0 +1,17 @@ +-- Центральный журнал аудита identity. На него пишут внутренние сервисы через +-- сервисный токен, поэтому внешних ключей нет: событие должно переживать удаление +-- субъекта (студента, преподавателя, группы и т.п.). +CREATE TABLE identity.audit_events ( + id uuid PRIMARY KEY, + occurred_at timestamptz NOT NULL DEFAULT now(), + actor_account_id uuid, + actor_login varchar(100), + action varchar(64) NOT NULL, + entity_type varchar(64) NOT NULL, + entity_id varchar(128), + course_id uuid, + student_id uuid, + details text NOT NULL DEFAULT '' +); +CREATE INDEX audit_events_occurred_idx ON identity.audit_events (occurred_at DESC); +CREATE INDEX audit_events_action_idx ON identity.audit_events (action); diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt new file mode 100644 index 0000000..a063630 --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt @@ -0,0 +1,320 @@ +package ru.ysu.lms.identity.audit + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +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.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.delete +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.request.RequestPostProcessor +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.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import ru.ysu.lms.identity.repo.AccountRepository +import ru.ysu.lms.identity.roster.CreateGroupRequest +import ru.ysu.lms.identity.roster.CreateStudentRequest +import ru.ysu.lms.identity.roster.GroupService +import java.util.UUID + +@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"]) +@Testcontainers +@Transactional +class AuditIntegrationTest { + companion object { + private const val TOKEN = "test-internal-token" + + @Container + val pg = + PostgreSQLContainer("postgres:18").apply { + withDatabaseName("lms") + withUsername("lms") + withPassword("lms") + } + + @JvmStatic + @DynamicPropertySource + fun properties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { pg.jdbcUrl } + registry.add("spring.datasource.username") { "lms" } + registry.add("spring.datasource.password") { "lms" } + registry.add("lms.internal.token") { TOKEN } + } + } + + @Autowired lateinit var auditEvents: AuditEventRepository + + @Autowired lateinit var accounts: AccountRepository + + @Autowired lateinit var groupService: GroupService + + @Autowired lateinit var context: WebApplicationContext + + private val objectMapper = ObjectMapper() + + private val mockMvc: MockMvc by lazy { + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun `internal write is listed newest first with all fields`() { + val actor = UUID.randomUUID() + val course = UUID.randomUUID() + val student = UUID.randomUUID() + val first = + AuditEventRequest( + actorAccountId = actor.toString(), + actorLogin = "admin.audit", + action = "AUDIT_TEST_FIRST", + entityType = "STUDENT", + entityId = student.toString(), + courseId = course.toString(), + studentId = student.toString(), + details = "first event", + ) + write(first) + write(first.copy(action = "AUDIT_TEST_SECOND", details = "second event")) + + mockMvc + .get("/api/v1/audit") { + with(admin()) + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].action") { value("AUDIT_TEST_SECOND") } + jsonPath("$[0].details") { value("second event") } + jsonPath("$[1].action") { value("AUDIT_TEST_FIRST") } + jsonPath("$[1].id") { isNotEmpty() } + jsonPath("$[1].occurredAt") { isNotEmpty() } + jsonPath("$[1].actorAccountId") { value(actor.toString()) } + jsonPath("$[1].actorLogin") { value("admin.audit") } + jsonPath("$[1].entityType") { value("STUDENT") } + jsonPath("$[1].entityId") { value(student.toString()) } + jsonPath("$[1].courseId") { value(course.toString()) } + jsonPath("$[1].studentId") { value(student.toString()) } + jsonPath("$[1].details") { value("first event") } + } + } + + @Test + fun `invalid or missing internal token is forbidden and writes nothing`() { + val request = + AuditEventRequest( + action = "AUDIT_UNAUTHORIZED", + entityType = "STUDENT", + entityId = UUID.randomUUID().toString(), + ) + + mockMvc + .post("/api/v1/internal/audit") { + header("X-Internal-Token", "wrong-token") + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + }.andExpect { + status { isForbidden() } + } + + mockMvc + .post("/api/v1/internal/audit") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + }.andExpect { + status { isForbidden() } + } + + assertEquals(0L, auditEvents.count()) + } + + @Test + fun `admin list requires admin role`() { + write(AuditEventRequest(action = "AUDIT_ROLE_CHECK", entityType = "STUDENT")) + + mockMvc + .get("/api/v1/audit") { + with(jwt().authorities(SimpleGrantedAuthority("ROLE_TEACHER"))) + }.andExpect { + status { isForbidden() } + } + + mockMvc + .get("/api/v1/audit") { + with(jwt().authorities(SimpleGrantedAuthority("ROLE_STUDENT"))) + }.andExpect { + status { isForbidden() } + } + + mockMvc + .get("/api/v1/audit") { + with(admin()) + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + } + } + + @Test + fun `action filter and limit are applied`() { + write(AuditEventRequest(action = "AUDIT_FILTER_A", entityType = "STUDENT", details = "oldest")) + write(AuditEventRequest(action = "AUDIT_FILTER_B", entityType = "STUDENT", details = "middle")) + write(AuditEventRequest(action = "AUDIT_FILTER_A", entityType = "STUDENT", details = "newest")) + + mockMvc + .get("/api/v1/audit") { + with(admin()) + param("action", "AUDIT_FILTER_A") + param("limit", "1") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].action") { value("AUDIT_FILTER_A") } + jsonPath("$[0].details") { value("newest") } + } + + mockMvc + .get("/api/v1/audit") { + with(admin()) + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].action") { value("AUDIT_FILTER_A") } + } + } + + @Test + fun `blank action or entity type is rejected`() { + writeExpectingBadRequest(AuditEventRequest(action = " ", entityType = "STUDENT")) + writeExpectingBadRequest(AuditEventRequest(action = "AUDIT_BLANK_ENTITY", entityType = " ")) + assertEquals(0L, auditEvents.count()) + } + + @Test + fun `invalid optional identifiers are ignored instead of failing the write`() { + write( + AuditEventRequest( + actorAccountId = "not-a-uuid", + actorLogin = "admin", + action = "AUDIT_LENIENT_IDS", + entityType = "STUDENT", + entityId = "external-id", + courseId = "broken", + studentId = "also-broken", + ), + ) + + mockMvc + .get("/api/v1/audit") { + with(admin()) + param("action", "AUDIT_LENIENT_IDS") + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(1) } + jsonPath("$[0].entityId") { value("external-id") } + jsonPath("$[0].actorAccountId") { doesNotExist() } + jsonPath("$[0].courseId") { doesNotExist() } + jsonPath("$[0].studentId") { doesNotExist() } + } + } + + @Test + fun `identity hooks record teacher create and student delete that survives deletion`() { + val actor = UUID.randomUUID() + val admin = admin(actor) + + mockMvc + .post("/api/v1/teachers") { + with(admin) + contentType = MediaType.APPLICATION_JSON + content = """{"login":"audit.teacher"}""" + }.andExpect { + status { isOk() } + } + + val teacherEvent = auditEvents.findTop200ByOrderByOccurredAtDesc().first { it.action == "TEACHER_CREATE" } + assertEquals("TEACHER", teacherEvent.entityType) + assertEquals(actor, teacherEvent.actorAccountId) + assertEquals("admin.audit", teacherEvent.actorLogin) + assertTrue(teacherEvent.details.contains("audit.teacher")) + + val teacher = accounts.findByLogin("audit.teacher")!! + accounts.delete(teacher) + accounts.flush() + assertTrue(auditEvents.findById(teacherEvent.id).isPresent) + assertFalse(accounts.findById(teacher.id).isPresent) + + val group = + groupService.create( + CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025), + ) + val credential = + groupService.createStudent( + UUID.fromString(group.id), + CreateStudentRequest(studentNo = "8801", lastName = "Удалов", firstName = "Иван"), + null, + ) + val studentId = UUID.fromString(credential.id) + + mockMvc + .delete("/api/v1/students/$studentId") { + with(admin) + param("confirm", "8801") + }.andExpect { + status { isOk() } + jsonPath("$.login") { value("8801") } + } + + val deleteEvent = auditEvents.findTop200ByOrderByOccurredAtDesc().first { it.action == "STUDENT_DELETE" } + assertEquals("STUDENT", deleteEvent.entityType) + assertEquals(studentId.toString(), deleteEvent.entityId) + assertEquals(studentId, deleteEvent.studentId) + assertEquals(actor, deleteEvent.actorAccountId) + assertTrue(deleteEvent.details.contains("8801")) + assertTrue(deleteEvent.details.contains("Удалов Иван")) + assertFalse(accounts.findById(studentId).isPresent) + assertTrue(auditEvents.findById(deleteEvent.id).isPresent) + } + + private fun write(request: AuditEventRequest) { + mockMvc + .post("/api/v1/internal/audit") { + header("X-Internal-Token", TOKEN) + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + }.andExpect { + status { isNoContent() } + } + } + + private fun writeExpectingBadRequest(request: AuditEventRequest) { + mockMvc + .post("/api/v1/internal/audit") { + header("X-Internal-Token", TOKEN) + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(request) + }.andExpect { + status { isBadRequest() } + } + } + + private fun admin(subject: UUID = UUID.randomUUID()): RequestPostProcessor = + jwt() + .jwt { builder -> builder.subject(subject.toString()).claim("login", "admin.audit") } + .authorities(SimpleGrantedAuthority("ROLE_ADMIN")) +} diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt index 3969042..6e3b217 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/internal/InternalControllerIntegrationTest.kt @@ -22,6 +22,7 @@ import org.testcontainers.junit.jupiter.Testcontainers import ru.ysu.lms.identity.domain.Account import ru.ysu.lms.identity.domain.Enrollment import ru.ysu.lms.identity.domain.GroupEntity +import ru.ysu.lms.identity.domain.GroupStatus import ru.ysu.lms.identity.domain.Student import ru.ysu.lms.identity.domain.StudentStatus import ru.ysu.lms.identity.repo.AccountRepository @@ -272,6 +273,62 @@ class InternalControllerIntegrationTest { } } + @Test + fun `valid token returns groups sorted by name with status`() { + val graduated = groups.save(GroupEntity(name = "ПИ-22БО", enrollmentYear = 2025, status = GroupStatus.GRADUATED)) + val active = groups.save(GroupEntity(name = "ПИ-21БО", enrollmentYear = 2025)) + + mockMvc + .get("/api/v1/internal/groups") { + header("X-Internal-Token", TOKEN) + }.andExpect { + status { isOk() } + jsonPath("$.length()") { value(2) } + jsonPath("$[0].id") { value(active.id.toString()) } + jsonPath("$[0].name") { value("ПИ-21БО") } + jsonPath("$[0].status") { value("ACTIVE") } + jsonPath("$[1].id") { value(graduated.id.toString()) } + jsonPath("$[1].name") { value("ПИ-22БО") } + jsonPath("$[1].status") { value("GRADUATED") } + } + } + + @Test + fun `groups with missing or invalid token are forbidden`() { + mockMvc + .get("/api/v1/internal/groups") { + }.andExpect { + status { isForbidden() } + } + + mockMvc + .get("/api/v1/internal/groups") { + header("X-Internal-Token", "wrong-token") + }.andExpect { + status { isForbidden() } + jsonPath("$.detail") { value("Неверный внутренний токен") } + } + } + + @Test + fun `groups response exposes no credentials or students`() { + group("ПИ-21БО") + + mockMvc + .get("/api/v1/internal/groups") { + header("X-Internal-Token", TOKEN) + }.andExpect { + status { isOk() } + jsonPath("$[0].id") { isNotEmpty() } + jsonPath("$[0].name") { value("ПИ-21БО") } + jsonPath("$[0].status") { isNotEmpty() } + jsonPath("$[0].tempPassword") { doesNotExist() } + jsonPath("$[0].passwordHash") { doesNotExist() } + jsonPath("$[0].login") { doesNotExist() } + jsonPath("$[0].students") { doesNotExist() } + } + } + private fun account(login: String): Account = accounts.save( Account(