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);
|
||||
Reference in New Issue
Block a user