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
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<InternalGroupSummaryDto> {
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<Void> {
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))

View File

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

View File

@@ -62,6 +62,20 @@ class InternalService(
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 пропускаются, дубликаты схлопываются. */
@Transactional(readOnly = true)
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.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<PromotionRunSummary> = 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}",
),
)
}
}

View File

@@ -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<GroupSummary> = 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(

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.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
}
}

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.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,
),
)
}
}

View File

@@ -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<TeacherDto> = 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
}
}

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