feat(identity): центральный аудит и внутренний список групп
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user