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
|
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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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(
|
||||||
|
|||||||
Reference in New Issue
Block a user