feat(academic): доступ к курсам и лабораторным по ролям
This commit is contained in:
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PutMapping
|
|||||||
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.academic.course.CourseAccessService
|
||||||
import ru.ysu.lms.academic.course.CourseBootstrapRequest
|
import ru.ysu.lms.academic.course.CourseBootstrapRequest
|
||||||
import ru.ysu.lms.academic.course.CourseBootstrapResult
|
import ru.ysu.lms.academic.course.CourseBootstrapResult
|
||||||
import ru.ysu.lms.academic.course.CourseBootstrapService
|
import ru.ysu.lms.academic.course.CourseBootstrapService
|
||||||
@@ -26,14 +27,22 @@ import java.util.UUID
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/courses")
|
@RequestMapping("/api/v1/courses")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("isAuthenticated()")
|
||||||
class CourseController(
|
class CourseController(
|
||||||
private val courseService: CourseService,
|
private val courseService: CourseService,
|
||||||
private val bootstrapService: CourseBootstrapService,
|
private val bootstrapService: CourseBootstrapService,
|
||||||
|
private val accessService: CourseAccessService,
|
||||||
) {
|
) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun list(): List<CourseSummary> = courseService.list()
|
fun list(): List<CourseSummary> = courseService.list()
|
||||||
|
|
||||||
|
/** Курсы текущего пользователя: все для admin, назначенные/по группам для остальных. */
|
||||||
|
@GetMapping("/mine")
|
||||||
|
fun mine(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): List<CourseSummary> = accessService.myCourses(jwt)
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun create(
|
fun create(
|
||||||
@RequestBody body: CourseCreateRequest,
|
@RequestBody body: CourseCreateRequest,
|
||||||
@@ -43,39 +52,49 @@ class CourseController(
|
|||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
fun detail(
|
fun detail(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
): CourseDetail = courseService.detail(id)
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): CourseDetail {
|
||||||
|
accessService.requireCourseAccess(id, jwt)
|
||||||
|
return courseService.detail(id)
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun update(
|
fun update(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody body: CourseUpdateRequest,
|
@RequestBody body: CourseUpdateRequest,
|
||||||
): CourseDetail = courseService.update(id, body)
|
): CourseDetail = courseService.update(id, body)
|
||||||
|
|
||||||
@PostMapping("/{id}/teachers")
|
@PostMapping("/{id}/teachers")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun assignTeacher(
|
fun assignTeacher(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody body: CourseTeacherRequest,
|
@RequestBody body: CourseTeacherRequest,
|
||||||
): CourseDetail = courseService.assignTeacher(id, body)
|
): CourseDetail = courseService.assignTeacher(id, body)
|
||||||
|
|
||||||
@DeleteMapping("/{id}/teachers/{accountId}")
|
@DeleteMapping("/{id}/teachers/{accountId}")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun removeTeacher(
|
fun removeTeacher(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@PathVariable accountId: String,
|
@PathVariable accountId: String,
|
||||||
): CourseDetail = courseService.removeTeacher(id, accountId)
|
): CourseDetail = courseService.removeTeacher(id, accountId)
|
||||||
|
|
||||||
@PostMapping("/{id}/groups")
|
@PostMapping("/{id}/groups")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun attachGroup(
|
fun attachGroup(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@RequestBody body: CourseGroupRequest,
|
@RequestBody body: CourseGroupRequest,
|
||||||
): CourseDetail = courseService.attachGroup(id, body)
|
): CourseDetail = courseService.attachGroup(id, body)
|
||||||
|
|
||||||
@DeleteMapping("/{id}/groups/{groupId}")
|
@DeleteMapping("/{id}/groups/{groupId}")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun detachGroup(
|
fun detachGroup(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
@PathVariable groupId: String,
|
@PathVariable groupId: String,
|
||||||
): CourseDetail = courseService.detachGroup(id, groupId)
|
): CourseDetail = courseService.detachGroup(id, groupId)
|
||||||
|
|
||||||
@PostMapping("/bootstrap")
|
@PostMapping("/bootstrap")
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun bootstrap(
|
fun bootstrap(
|
||||||
@RequestBody body: CourseBootstrapRequest,
|
@RequestBody body: CourseBootstrapRequest,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package ru.ysu.lms.academic.controller
|
|||||||
|
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
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.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
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
|
||||||
@@ -12,6 +14,7 @@ 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 org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import ru.ysu.lms.academic.course.CourseAccessService
|
||||||
import ru.ysu.lms.academic.lab.LabCopyRequest
|
import ru.ysu.lms.academic.lab.LabCopyRequest
|
||||||
import ru.ysu.lms.academic.lab.LabCreateRequest
|
import ru.ysu.lms.academic.lab.LabCreateRequest
|
||||||
import ru.ysu.lms.academic.lab.LabDetail
|
import ru.ysu.lms.academic.lab.LabDetail
|
||||||
@@ -27,18 +30,36 @@ import java.util.UUID
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/labs")
|
@RequestMapping("/api/v1/labs")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
class LabController(
|
class LabController(
|
||||||
private val labService: LabService,
|
private val labService: LabService,
|
||||||
|
private val accessService: CourseAccessService,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Без `courseId` список ограничен доступными курсами (admin видит все).
|
||||||
|
* С `courseId` доступ к курсу проверяется явно.
|
||||||
|
*/
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun list(
|
fun list(
|
||||||
@RequestParam(required = false) courseId: UUID?,
|
@RequestParam(required = false) courseId: UUID?,
|
||||||
): List<LabSummary> = labService.list(courseId)
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): List<LabSummary> {
|
||||||
|
if (courseId != null) {
|
||||||
|
accessService.requireCourseAccess(courseId, jwt)
|
||||||
|
return labService.list(courseId)
|
||||||
|
}
|
||||||
|
val accessible = accessService.accessibleCourseIds(jwt) ?: return labService.list()
|
||||||
|
return labService.list(accessible)
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
fun detail(
|
fun detail(
|
||||||
@PathVariable id: UUID,
|
@PathVariable id: UUID,
|
||||||
): LabDetail = labService.detail(id)
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): LabDetail {
|
||||||
|
accessService.requireLabAccess(id, jwt)
|
||||||
|
return labService.detail(id)
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/copy")
|
@PostMapping("/{id}/copy")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package ru.ysu.lms.academic.course
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import ru.ysu.lms.academic.domain.Course
|
||||||
|
import ru.ysu.lms.academic.identity.IdentityClient
|
||||||
|
import ru.ysu.lms.academic.repo.CourseGroupRepository
|
||||||
|
import ru.ysu.lms.academic.repo.CourseRepository
|
||||||
|
import ru.ysu.lms.academic.repo.CourseTeacherRepository
|
||||||
|
import ru.ysu.lms.academic.repo.LabRepository
|
||||||
|
import ru.ysu.lms.common.ForbiddenException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Курсовая область видимости для не-администраторов.
|
||||||
|
*
|
||||||
|
* Администратор видит все курсы, включая архивные. Преподаватель — только курсы с активным
|
||||||
|
* назначением `course_teachers`; студент — только активные курсы групп, в которых он состоит
|
||||||
|
* сейчас (по данным identity-service). Архивный курс для не-администратора недоступен.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class CourseAccessService(
|
||||||
|
private val courses: CourseRepository,
|
||||||
|
private val teachers: CourseTeacherRepository,
|
||||||
|
private val groups: CourseGroupRepository,
|
||||||
|
private val labs: LabRepository,
|
||||||
|
private val identity: IdentityClient,
|
||||||
|
private val courseService: CourseService,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* `null` — администратор, ограничений нет; пустое множество — доступных курсов нет.
|
||||||
|
* Набор содержит только активные курсы.
|
||||||
|
*/
|
||||||
|
fun accessibleCourseIds(jwt: Jwt): Set<UUID>? =
|
||||||
|
when (role(jwt)) {
|
||||||
|
ROLE_ADMIN -> null
|
||||||
|
ROLE_TEACHER -> {
|
||||||
|
val assignments = teachers.findByAccountIdAndRemovedAtIsNull(requireAccountId(jwt))
|
||||||
|
activeCourseIds(assignments.mapNotNull { it.course?.id })
|
||||||
|
}
|
||||||
|
ROLE_STUDENT -> {
|
||||||
|
val groupIds = identity.currentGroupIds(jwt.tokenValue)
|
||||||
|
if (groupIds.isEmpty()) {
|
||||||
|
emptySet()
|
||||||
|
} else {
|
||||||
|
activeCourseIds(groups.findByGroupIdInAndRemovedAtIsNull(groupIds).mapNotNull { it.course?.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requireCourseAccess(
|
||||||
|
courseId: UUID,
|
||||||
|
jwt: Jwt,
|
||||||
|
) {
|
||||||
|
val allowed = accessibleCourseIds(jwt) ?: return
|
||||||
|
if (courseId !in allowed) throw ForbiddenException("Курс недоступен")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лаборатория без курса доступна только администратору: иначе её нельзя было бы
|
||||||
|
* сопоставить с курсовой областью видимости.
|
||||||
|
*/
|
||||||
|
fun requireLabAccess(
|
||||||
|
labId: UUID,
|
||||||
|
jwt: Jwt,
|
||||||
|
) {
|
||||||
|
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
|
||||||
|
val courseId = lab.course?.id
|
||||||
|
if (courseId == null) {
|
||||||
|
if (role(jwt) != ROLE_ADMIN) throw ForbiddenException("Лабораторная недоступна")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requireCourseAccess(courseId, jwt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun myCourses(jwt: Jwt): List<CourseSummary> {
|
||||||
|
val allowed = accessibleCourseIds(jwt) ?: return courseService.list()
|
||||||
|
if (allowed.isEmpty()) return emptyList()
|
||||||
|
val allowedIds = allowed.mapTo(HashSet()) { it.toString() }
|
||||||
|
return courseService.list().filter { it.id in allowedIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeCourseIds(courseIds: Collection<UUID>): Set<UUID> {
|
||||||
|
if (courseIds.isEmpty()) return emptySet()
|
||||||
|
return courses.findAllById(courseIds).filter { it.status == Course.STATUS_ACTIVE }.mapTo(HashSet()) { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireAccountId(jwt: Jwt): UUID =
|
||||||
|
runCatching { UUID.fromString(jwt.subject) }
|
||||||
|
.getOrElse { throw ForbiddenException("Некорректный идентификатор пользователя") }
|
||||||
|
|
||||||
|
private fun role(jwt: Jwt): String? = jwt.getClaimAsString("role")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ROLE_ADMIN = "ADMIN"
|
||||||
|
const val ROLE_TEACHER = "TEACHER"
|
||||||
|
const val ROLE_STUDENT = "STUDENT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package ru.ysu.lms.academic.identity
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.core.ParameterizedTypeReference
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.client.JdkClientHttpRequestFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.web.client.RestClient
|
||||||
|
import org.springframework.web.client.RestClientException
|
||||||
|
import ru.ysu.lms.common.LmsException
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клиент identity-service. Академическому сервису нужны только текущие группы пользователя,
|
||||||
|
* поэтому из ответа `/api/v1/groups/me` читается минимальный контракт `{id}`.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class IdentityClient(
|
||||||
|
@Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String,
|
||||||
|
) {
|
||||||
|
private val client = restClient(baseUrl)
|
||||||
|
|
||||||
|
/** Идентификаторы групп, в которых пользователь состоит сейчас. */
|
||||||
|
fun currentGroupIds(token: String): List<UUID> =
|
||||||
|
try {
|
||||||
|
val groups =
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/groups/me")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
|
||||||
|
.retrieve()
|
||||||
|
.body(GROUP_LIST) ?: emptyList()
|
||||||
|
groups.map { UUID.fromString(it.id) }
|
||||||
|
} catch (e: RestClientException) {
|
||||||
|
throw IdentityUnavailableException().apply { initCause(e) }
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity недоступен или ответил ошибкой. Это не отказ в доступе пользователю,
|
||||||
|
* а сбой инфраструктуры, поэтому 502 вместо 403.
|
||||||
|
*/
|
||||||
|
class IdentityUnavailableException(
|
||||||
|
message: String = "Сервис идентификации недоступен",
|
||||||
|
) : LmsException(HttpStatus.BAD_GATEWAY, message, code = "IDENTITY_UNAVAILABLE")
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
internal data class GroupRef(
|
||||||
|
val id: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val GROUP_LIST = object : ParameterizedTypeReference<List<GroupRef>>() {}
|
||||||
|
|
||||||
|
/** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */
|
||||||
|
private const val CONNECT_TIMEOUT_SECONDS = 3L
|
||||||
|
private const val READ_TIMEOUT_SECONDS = 30L
|
||||||
|
|
||||||
|
private fun restClient(baseUrl: String): RestClient =
|
||||||
|
RestClient
|
||||||
|
.builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.requestFactory(
|
||||||
|
JdkClientHttpRequestFactory(
|
||||||
|
HttpClient
|
||||||
|
.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
|
||||||
|
.build(),
|
||||||
|
).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) },
|
||||||
|
).build()
|
||||||
@@ -103,23 +103,16 @@ class LabService(
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun list(courseId: UUID? = null): List<LabSummary> {
|
fun list(courseId: UUID? = null): List<LabSummary> {
|
||||||
val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId)
|
val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId)
|
||||||
return labList.map { lab ->
|
return labList.map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) }
|
||||||
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
|
}
|
||||||
LabSummary(
|
|
||||||
id = lab.id.toString(),
|
/** Список лабораторных только по доступным курсам (курсовая область видимости). */
|
||||||
number = lab.number,
|
@Transactional(readOnly = true)
|
||||||
title = lab.title,
|
fun list(courseIds: Collection<UUID>): List<LabSummary> {
|
||||||
status = lab.status,
|
if (courseIds.isEmpty()) return emptyList()
|
||||||
semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—",
|
return labs
|
||||||
deadlineAt = lab.deadlineAt?.toString(),
|
.findAllByCourseIdInOrderByNumberAsc(courseIds)
|
||||||
defenseAt = lab.defenseAt?.toString(),
|
.map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) }
|
||||||
pointsMax = lab.pointsMax,
|
|
||||||
variantCount = variants.size,
|
|
||||||
courseId = lab.course?.id?.toString(),
|
|
||||||
courseNo = lab.course?.courseNo,
|
|
||||||
courseTitle = lab.course?.title,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -399,14 +392,6 @@ class LabService(
|
|||||||
}
|
}
|
||||||
return semesters.save(Semester(title = t, year = y, active = true))
|
return semesters.save(Semester(title = t, year = y, active = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseInstant(value: String?): Instant? {
|
|
||||||
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
|
||||||
return runCatching { Instant.parse(v) }
|
|
||||||
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
|
|
||||||
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
|
||||||
.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Явно заданный курс или единственный существующий (для импорта без courseId). */
|
/** Явно заданный курс или единственный существующий (для импорта без courseId). */
|
||||||
@@ -428,3 +413,27 @@ private fun Course.rejectArchived(): Course {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
|
||||||
|
|
||||||
|
private fun Lab.toSummary(variants: List<Variant>): LabSummary =
|
||||||
|
LabSummary(
|
||||||
|
id = id.toString(),
|
||||||
|
number = number,
|
||||||
|
title = title,
|
||||||
|
status = status,
|
||||||
|
semesterTitle = semester?.let { "${it.title} ${it.year}" } ?: "—",
|
||||||
|
deadlineAt = deadlineAt?.toString(),
|
||||||
|
defenseAt = defenseAt?.toString(),
|
||||||
|
pointsMax = pointsMax,
|
||||||
|
variantCount = variants.size,
|
||||||
|
courseId = course?.id?.toString(),
|
||||||
|
courseNo = course?.courseNo,
|
||||||
|
courseTitle = course?.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseInstant(value: String?): Instant? {
|
||||||
|
val v = value?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return runCatching { Instant.parse(v) }
|
||||||
|
.recoverCatching { OffsetDateTime.parse(v).toInstant() }
|
||||||
|
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface CourseTeacherRepository : JpaRepository<CourseTeacher, UUID> {
|
|||||||
accountId: UUID,
|
accountId: UUID,
|
||||||
): CourseTeacher?
|
): CourseTeacher?
|
||||||
|
|
||||||
|
fun findByAccountIdAndRemovedAtIsNull(accountId: UUID): List<CourseTeacher>
|
||||||
|
|
||||||
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
|
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,5 +33,7 @@ interface CourseGroupRepository : JpaRepository<CourseGroup, UUID> {
|
|||||||
groupId: UUID,
|
groupId: UUID,
|
||||||
): CourseGroup?
|
): CourseGroup?
|
||||||
|
|
||||||
|
fun findByGroupIdInAndRemovedAtIsNull(groupIds: Collection<UUID>): List<CourseGroup>
|
||||||
|
|
||||||
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
|
fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface LabRepository : JpaRepository<Lab, UUID> {
|
|||||||
|
|
||||||
fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List<Lab>
|
fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List<Lab>
|
||||||
|
|
||||||
|
fun findAllByCourseIdInOrderByNumberAsc(courseIds: Collection<UUID>): List<Lab>
|
||||||
|
|
||||||
fun existsByNumberAndCourseId(
|
fun existsByNumberAndCourseId(
|
||||||
number: Int,
|
number: Int,
|
||||||
courseId: UUID,
|
courseId: UUID,
|
||||||
|
|||||||
@@ -43,3 +43,5 @@ springdoc:
|
|||||||
lms:
|
lms:
|
||||||
content:
|
content:
|
||||||
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}
|
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}
|
||||||
|
identity:
|
||||||
|
base-url: ${LMS_IDENTITY_URI:http://localhost:8081}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package ru.ysu.lms.academic.course
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.assertj.core.api.Assertions.assertThatCode
|
||||||
|
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||||
|
import org.assertj.core.api.Assertions.catchThrowable
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import ru.ysu.lms.academic.domain.Course
|
||||||
|
import ru.ysu.lms.academic.domain.CourseGroup
|
||||||
|
import ru.ysu.lms.academic.domain.CourseTeacher
|
||||||
|
import ru.ysu.lms.academic.domain.Lab
|
||||||
|
import ru.ysu.lms.academic.identity.IdentityClient
|
||||||
|
import ru.ysu.lms.academic.identity.IdentityUnavailableException
|
||||||
|
import ru.ysu.lms.academic.repo.CourseGroupRepository
|
||||||
|
import ru.ysu.lms.academic.repo.CourseRepository
|
||||||
|
import ru.ysu.lms.academic.repo.CourseTeacherRepository
|
||||||
|
import ru.ysu.lms.academic.repo.LabRepository
|
||||||
|
import ru.ysu.lms.common.ForbiddenException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
|
import java.util.Optional
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class CourseAccessServiceTest {
|
||||||
|
private val courses = mockk<CourseRepository>()
|
||||||
|
private val teachers = mockk<CourseTeacherRepository>()
|
||||||
|
private val groups = mockk<CourseGroupRepository>()
|
||||||
|
private val labs = mockk<LabRepository>()
|
||||||
|
private val identity = mockk<IdentityClient>()
|
||||||
|
private val courseService = mockk<CourseService>()
|
||||||
|
|
||||||
|
private val service = CourseAccessService(courses, teachers, groups, labs, identity, courseService)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admin bypasses checks and has no course restriction`() {
|
||||||
|
val archived = course(Course.STATUS_ARCHIVED)
|
||||||
|
val admin = jwt(CourseAccessService.ROLE_ADMIN)
|
||||||
|
|
||||||
|
assertThatCode { service.requireCourseAccess(archived.id, admin) }.doesNotThrowAnyException()
|
||||||
|
assertThat(service.accessibleCourseIds(admin)).isNull()
|
||||||
|
verify(exactly = 0) { courses.findAllById(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `teacher with active assignment can access active course`() {
|
||||||
|
val active = course()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||||
|
listOf(CourseTeacher(course = active, accountId = teacherId))
|
||||||
|
every { courses.findAllById(any()) } returns listOf(active)
|
||||||
|
|
||||||
|
assertThatCode {
|
||||||
|
service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
|
||||||
|
}.doesNotThrowAnyException()
|
||||||
|
verify(exactly = 0) { identity.currentGroupIds(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `teacher cannot access a foreign course`() {
|
||||||
|
val assigned = course()
|
||||||
|
val foreign = course()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||||
|
listOf(CourseTeacher(course = assigned, accountId = teacherId))
|
||||||
|
every { courses.findAllById(any()) } returns listOf(assigned)
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
|
||||||
|
}.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `teacher with removed assignment is denied`() {
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns emptyList()
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireCourseAccess(course().id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
|
||||||
|
}.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
verify(exactly = 0) { courses.findAllById(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `teacher cannot access archived course`() {
|
||||||
|
val archived = course(Course.STATUS_ARCHIVED)
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||||
|
listOf(CourseTeacher(course = archived, accountId = teacherId))
|
||||||
|
every { courses.findAllById(any()) } returns listOf(archived)
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_TEACHER, teacherId))
|
||||||
|
}.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student can access course of a current group`() {
|
||||||
|
val active = course()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
stubStudentGroup(active, groupId)
|
||||||
|
|
||||||
|
assertThatCode {
|
||||||
|
service.requireCourseAccess(active.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}.doesNotThrowAnyException()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student cannot access course of a foreign group`() {
|
||||||
|
val foreign = course()
|
||||||
|
val other = course()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
stubStudentGroup(other, groupId)
|
||||||
|
every { courses.findAllById(any()) } returns listOf(other)
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireCourseAccess(foreign.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student without current groups is denied without querying course groups`() {
|
||||||
|
every { identity.currentGroupIds(TOKEN) } returns emptyList()
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireCourseAccess(course().id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
verify(exactly = 0) { groups.findByGroupIdInAndRemovedAtIsNull(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student cannot access archived course of a current group`() {
|
||||||
|
val archived = course(Course.STATUS_ARCHIVED)
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
stubStudentGroup(archived, groupId)
|
||||||
|
every { courses.findAllById(any()) } returns listOf(archived)
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireCourseAccess(archived.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `identity outage is reported as bad gateway instead of forbidden`() {
|
||||||
|
every { identity.currentGroupIds(TOKEN) } throws IdentityUnavailableException()
|
||||||
|
|
||||||
|
val thrown =
|
||||||
|
catchThrowable {
|
||||||
|
service.requireCourseAccess(course().id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(thrown).isInstanceOf(IdentityUnavailableException::class.java)
|
||||||
|
assertThat((thrown as IdentityUnavailableException).status).isEqualTo(HttpStatus.BAD_GATEWAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admin my courses contains every course`() {
|
||||||
|
val active = course()
|
||||||
|
val archived = course(Course.STATUS_ARCHIVED)
|
||||||
|
every { courseService.list() } returns listOf(summary(active), summary(archived))
|
||||||
|
|
||||||
|
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_ADMIN)).map { it.id })
|
||||||
|
.containsExactly(active.id.toString(), archived.id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `teacher my courses contains only active assigned courses`() {
|
||||||
|
val active = course()
|
||||||
|
val archived = course(Course.STATUS_ARCHIVED)
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||||
|
listOf(
|
||||||
|
CourseTeacher(course = active, accountId = teacherId),
|
||||||
|
CourseTeacher(course = archived, accountId = teacherId),
|
||||||
|
)
|
||||||
|
every { courses.findAllById(any()) } returns listOf(active, archived)
|
||||||
|
every { courseService.list() } returns listOf(summary(active), summary(archived))
|
||||||
|
|
||||||
|
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_TEACHER, teacherId)).map { it.id })
|
||||||
|
.containsExactly(active.id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student my courses contains only courses of current groups`() {
|
||||||
|
val active = course()
|
||||||
|
val foreign = course()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
stubStudentGroup(active, groupId)
|
||||||
|
every { courses.findAllById(any()) } returns listOf(active)
|
||||||
|
every { courseService.list() } returns listOf(summary(active), summary(foreign))
|
||||||
|
|
||||||
|
assertThat(service.myCourses(jwt(CourseAccessService.ROLE_STUDENT)).map { it.id })
|
||||||
|
.containsExactly(active.id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lab access follows its course`() {
|
||||||
|
val active = course()
|
||||||
|
val lab = Lab(number = 1, title = "Лабораторная", course = active)
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
|
stubStudentGroup(active, groupId)
|
||||||
|
|
||||||
|
assertThatCode {
|
||||||
|
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}.doesNotThrowAnyException()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lab without course is admin only`() {
|
||||||
|
val lab = Lab(number = 1, title = "Без курса")
|
||||||
|
every { labs.findById(lab.id) } returns Optional.of(lab)
|
||||||
|
|
||||||
|
assertThatCode {
|
||||||
|
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_ADMIN))
|
||||||
|
}.doesNotThrowAnyException()
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireLabAccess(lab.id, jwt(CourseAccessService.ROLE_STUDENT))
|
||||||
|
}.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing lab is not found`() {
|
||||||
|
every { labs.findById(any()) } returns Optional.empty()
|
||||||
|
|
||||||
|
assertThatThrownBy {
|
||||||
|
service.requireLabAccess(UUID.randomUUID(), jwt(CourseAccessService.ROLE_ADMIN))
|
||||||
|
}.isInstanceOf(NotFoundException::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stubStudentGroup(
|
||||||
|
course: Course,
|
||||||
|
groupId: UUID,
|
||||||
|
) {
|
||||||
|
every { identity.currentGroupIds(TOKEN) } returns listOf(groupId)
|
||||||
|
every { groups.findByGroupIdInAndRemovedAtIsNull(any()) } returns
|
||||||
|
listOf(CourseGroup(course = course, groupId = groupId))
|
||||||
|
every { courses.findAllById(any()) } returns listOf(course)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun course(status: String = Course.STATUS_ACTIVE): Course = Course(title = "Курс", status = status)
|
||||||
|
|
||||||
|
private fun summary(course: Course): CourseSummary =
|
||||||
|
CourseSummary(
|
||||||
|
id = course.id.toString(),
|
||||||
|
courseNo = 1,
|
||||||
|
title = course.title,
|
||||||
|
ownerAccountId = course.ownerAccountId.toString(),
|
||||||
|
status = course.status,
|
||||||
|
teacherCount = 0,
|
||||||
|
groupCount = 0,
|
||||||
|
labCount = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun jwt(
|
||||||
|
role: String,
|
||||||
|
subject: UUID = UUID.randomUUID(),
|
||||||
|
): Jwt =
|
||||||
|
Jwt
|
||||||
|
.withTokenValue(TOKEN)
|
||||||
|
.header("alg", "none")
|
||||||
|
.subject(subject.toString())
|
||||||
|
.claim("role", role)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TOKEN = "token"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ spring:
|
|||||||
- id: identity
|
- id: identity
|
||||||
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
|
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
|
||||||
predicates:
|
predicates:
|
||||||
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**
|
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**,/api/v1/teachers/**
|
||||||
- id: academic
|
- id: academic
|
||||||
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
|
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
|
||||||
predicates:
|
predicates:
|
||||||
|
|||||||
Reference in New Issue
Block a user