feat(identity): центральный аудит и внутренний список групп

This commit is contained in:
2026-10-27 20:39:25 +03:00
parent c6ca981eca
commit 4d97feaf9e
17 changed files with 818 additions and 13 deletions

View File

@@ -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,
)

View File

@@ -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<AuditEventDto> = auditService.list(limit, action)
}

View File

@@ -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,
)

View File

@@ -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 = "",
)

View File

@@ -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<AuditEvent, UUID> {
fun findTop200ByOrderByOccurredAtDesc(): List<AuditEvent>
fun findAllByOrderByOccurredAtDescIdDesc(pageable: Pageable): List<AuditEvent>
fun findAllByActionOrderByOccurredAtDescIdDesc(
action: String,
pageable: Pageable,
): List<AuditEvent>
}

View File

@@ -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<AuditEventDto> {
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
}
}

View File

@@ -1,6 +1,7 @@
package ru.ysu.lms.identity.internal package ru.ysu.lms.identity.internal
import org.springframework.beans.factory.annotation.Value 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.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping 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.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.common.ForbiddenException 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.nio.charset.StandardCharsets
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
@@ -21,6 +24,7 @@ import java.util.UUID
@RequestMapping("/api/v1/internal") @RequestMapping("/api/v1/internal")
class InternalController( class InternalController(
private val service: InternalService, private val service: InternalService,
private val auditService: AuditService,
@Value("\${lms.internal.token}") private val internalToken: String, @Value("\${lms.internal.token}") private val internalToken: String,
) { ) {
@GetMapping("/students/{studentId}/context") @GetMapping("/students/{studentId}/context")
@@ -41,6 +45,14 @@ class InternalController(
return service.lookupAccounts(request.ids) return service.lookupAccounts(request.ids)
} }
@GetMapping("/groups")
fun groups(
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
): List<InternalGroupSummaryDto> {
ensureToken(token)
return service.groups()
}
@GetMapping("/groups/{groupId}") @GetMapping("/groups/{groupId}")
fun group( fun group(
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?, @RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
@@ -50,6 +62,17 @@ class InternalController(
return service.group(groupId) return service.group(groupId)
} }
/** Запись в центральный журнал аудита от academic/progress и других сервисов. */
@PostMapping("/audit")
fun audit(
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
@RequestBody request: AuditEventRequest,
): ResponseEntity<Void> {
ensureToken(token)
auditService.record(request)
return ResponseEntity.noContent().build()
}
private fun ensureToken(token: String?) { private fun ensureToken(token: String?) {
if (token == null || if (token == null ||
!MessageDigest.isEqual(token.toByteArray(StandardCharsets.UTF_8), internalToken.toByteArray(StandardCharsets.UTF_8)) !MessageDigest.isEqual(token.toByteArray(StandardCharsets.UTF_8), internalToken.toByteArray(StandardCharsets.UTF_8))

View File

@@ -30,6 +30,13 @@ data class InternalGroupDto(
val students: List<InternalGroupStudentDto>, val students: List<InternalGroupStudentDto>,
) )
/** Краткая карточка группы для управления курсами преподавателя. */
data class InternalGroupSummaryDto(
val id: String,
val name: String,
val status: String,
)
data class InternalGroupStudentDto( data class InternalGroupStudentDto(
val id: String, val id: String,
val studentNo: String, val studentNo: String,

View File

@@ -62,6 +62,20 @@ class InternalService(
return InternalGroupDto(id = group.id.toString(), name = group.name, students = members) return InternalGroupDto(id = group.id.toString(), name = group.name, students = members)
} }
/** Все группы без состава и учётных данных, отсортированные по названию и id. */
@Transactional(readOnly = true)
fun groups(): List<InternalGroupSummaryDto> =
groups
.findAll()
.sortedWith(compareBy({ it.name }, { it.id }))
.map { group ->
InternalGroupSummaryDto(
id = group.id.toString(),
name = group.name,
status = group.status.name,
)
}
/** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */ /** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun lookupAccounts(ids: List<String>): List<AccountInfoDto> { fun lookupAccounts(ids: List<String>): List<AccountInfoDto> {

View File

@@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController 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 import java.util.UUID
@RestController @RestController
@@ -16,6 +18,7 @@ import java.util.UUID
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class PromotionController( class PromotionController(
private val promotionService: PromotionService, private val promotionService: PromotionService,
private val auditService: AuditService,
) { ) {
@GetMapping @GetMapping
fun list(): List<PromotionRunSummary> = promotionService.list() fun list(): List<PromotionRunSummary> = promotionService.list()
@@ -29,23 +32,54 @@ class PromotionController(
fun dryRun( fun dryRun(
@RequestBody(required = false) body: DryRunRequest?, @RequestBody(required = false) body: DryRunRequest?,
@AuthenticationPrincipal jwt: Jwt, @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") @PostMapping("/{id}/apply")
fun apply( fun apply(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt, @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") @PostMapping("/{id}/cancel")
fun cancel( fun cancel(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt, @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") @PostMapping("/{id}/rollback")
fun rollback( fun rollback(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt, @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}",
),
)
}
} }

View File

@@ -17,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.common.BadRequestException 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.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.UUID import java.util.UUID
@@ -27,6 +29,7 @@ import java.util.UUID
class GroupController( class GroupController(
private val groupService: GroupService, private val groupService: GroupService,
private val importService: GroupImportService, private val importService: GroupImportService,
private val auditService: AuditService,
) { ) {
@GetMapping @GetMapping
fun list(): List<GroupSummary> = groupService.list() fun list(): List<GroupSummary> = groupService.list()
@@ -91,7 +94,19 @@ class GroupController(
@PostMapping("/{id}/archive") @PostMapping("/{id}/archive")
fun archive( fun archive(
@PathVariable id: UUID, @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") @GetMapping("/{id}/credentials")
fun credentials( fun credentials(

View File

@@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController 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 import java.util.UUID
@RestController @RestController
@@ -17,6 +19,7 @@ import java.util.UUID
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class StudentController( class StudentController(
private val groupService: GroupService, private val groupService: GroupService,
private val auditService: AuditService,
) { ) {
@PostMapping("/{id}/reset-password") @PostMapping("/{id}/reset-password")
fun resetPassword( fun resetPassword(
@@ -35,5 +38,17 @@ class StudentController(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestParam("confirm") confirm: String, @RequestParam("confirm") confirm: String,
@AuthenticationPrincipal jwt: Jwt, @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
}
} }

View File

@@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController 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 import java.util.UUID
@RestController @RestController
@@ -16,40 +18,86 @@ import java.util.UUID
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class StudentLifecycleController( class StudentLifecycleController(
private val lifecycle: StudentLifecycleService, private val lifecycle: StudentLifecycleService,
private val auditService: AuditService,
) { ) {
@PostMapping("/{id}/academic-leave") @PostMapping("/{id}/academic-leave")
fun academicLeave( fun academicLeave(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody(required = false) body: StudentReasonRequest?, @RequestBody(required = false) body: StudentReasonRequest?,
@AuthenticationPrincipal jwt: Jwt, @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") @PostMapping("/{id}/return")
fun returnFromLeave( fun returnFromLeave(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: StudentGroupRequest, @Valid @RequestBody body: StudentGroupRequest,
@AuthenticationPrincipal jwt: Jwt, @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") @PostMapping("/{id}/expel")
fun expel( fun expel(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody(required = false) body: StudentReasonRequest?, @RequestBody(required = false) body: StudentReasonRequest?,
@AuthenticationPrincipal jwt: Jwt, @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") @PostMapping("/{id}/archive")
fun archive( fun archive(
@PathVariable id: UUID, @PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt, @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") @PostMapping("/{id}/restore")
fun restore( fun restore(
@PathVariable id: UUID, @PathVariable id: UUID,
@Valid @RequestBody body: StudentGroupRequest, @Valid @RequestBody body: StudentGroupRequest,
@AuthenticationPrincipal jwt: Jwt, @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 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,
),
)
}
} }

View File

@@ -1,12 +1,16 @@
package ru.ysu.lms.identity.roster package ru.ysu.lms.identity.roster
import org.springframework.security.access.prepost.PreAuthorize 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.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController 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 import java.util.UUID
@RestController @RestController
@@ -14,6 +18,7 @@ import java.util.UUID
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class TeacherController( class TeacherController(
private val teacherService: TeacherService, private val teacherService: TeacherService,
private val auditService: AuditService,
) { ) {
@GetMapping @GetMapping
fun list(): List<TeacherDto> = teacherService.list() fun list(): List<TeacherDto> = teacherService.list()
@@ -21,10 +26,33 @@ class TeacherController(
@PostMapping @PostMapping
fun create( fun create(
@RequestBody body: TeacherCreateRequest, @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") @PostMapping("/{id}/reset-password")
fun resetPassword( fun resetPassword(
@PathVariable id: UUID, @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
}
} }

View File

@@ -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);

View File

@@ -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<DefaultMockMvcBuilder>(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"))
}

View File

@@ -22,6 +22,7 @@ import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.domain.Account import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.Enrollment import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity 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.Student
import ru.ysu.lms.identity.domain.StudentStatus import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository 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 = private fun account(login: String): Account =
accounts.save( accounts.save(
Account( Account(