From 8cc658bf972509ed85fca44556e8c010db63c36b Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 27 Oct 2026 15:46:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(academic):=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=20=D0=BA=20=D0=BA=D1=83=D1=80=D1=81=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=B8=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=20=D0=BF=D0=BE=20=D1=80=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../academic/controller/CourseController.kt | 23 +- .../lms/academic/controller/LabController.kt | 25 +- .../academic/course/CourseAccessService.kt | 101 +++++++ .../lms/academic/identity/IdentityClient.kt | 75 +++++ .../ru/ysu/lms/academic/lab/LabService.kt | 59 ++-- .../lms/academic/repo/CourseRepositories.kt | 4 + .../ru/ysu/lms/academic/repo/LabRepository.kt | 2 + .../src/main/resources/application.yml | 2 + .../course/CourseAccessServiceTest.kt | 272 ++++++++++++++++++ .../src/main/resources/application.yml | 2 +- 10 files changed, 535 insertions(+), 30 deletions(-) create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt create mode 100644 services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceTest.kt diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt index 2373cc4..9693752 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/CourseController.kt @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PutMapping 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.academic.course.CourseAccessService import ru.ysu.lms.academic.course.CourseBootstrapRequest import ru.ysu.lms.academic.course.CourseBootstrapResult import ru.ysu.lms.academic.course.CourseBootstrapService @@ -26,14 +27,22 @@ import java.util.UUID @RestController @RequestMapping("/api/v1/courses") -@PreAuthorize("hasAuthority('ROLE_ADMIN')") +@PreAuthorize("isAuthenticated()") class CourseController( private val courseService: CourseService, private val bootstrapService: CourseBootstrapService, + private val accessService: CourseAccessService, ) { @GetMapping + @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun list(): List = courseService.list() + /** Курсы текущего пользователя: все для admin, назначенные/по группам для остальных. */ + @GetMapping("/mine") + fun mine( + @AuthenticationPrincipal jwt: Jwt, + ): List = accessService.myCourses(jwt) + @PostMapping fun create( @RequestBody body: CourseCreateRequest, @@ -43,39 +52,49 @@ class CourseController( @GetMapping("/{id}") fun detail( @PathVariable id: UUID, - ): CourseDetail = courseService.detail(id) + @AuthenticationPrincipal jwt: Jwt, + ): CourseDetail { + accessService.requireCourseAccess(id, jwt) + return courseService.detail(id) + } @PutMapping("/{id}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun update( @PathVariable id: UUID, @RequestBody body: CourseUpdateRequest, ): CourseDetail = courseService.update(id, body) @PostMapping("/{id}/teachers") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun assignTeacher( @PathVariable id: UUID, @RequestBody body: CourseTeacherRequest, ): CourseDetail = courseService.assignTeacher(id, body) @DeleteMapping("/{id}/teachers/{accountId}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun removeTeacher( @PathVariable id: UUID, @PathVariable accountId: String, ): CourseDetail = courseService.removeTeacher(id, accountId) @PostMapping("/{id}/groups") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun attachGroup( @PathVariable id: UUID, @RequestBody body: CourseGroupRequest, ): CourseDetail = courseService.attachGroup(id, body) @DeleteMapping("/{id}/groups/{groupId}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun detachGroup( @PathVariable id: UUID, @PathVariable groupId: String, ): CourseDetail = courseService.detachGroup(id, groupId) @PostMapping("/bootstrap") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun bootstrap( @RequestBody body: CourseBootstrapRequest, @AuthenticationPrincipal jwt: Jwt, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt index 7d52947..aaace40 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt @@ -2,6 +2,8 @@ package ru.ysu.lms.academic.controller import jakarta.validation.Valid 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.GetMapping 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.RestController 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.LabCreateRequest import ru.ysu.lms.academic.lab.LabDetail @@ -27,18 +30,36 @@ import java.util.UUID @RestController @RequestMapping("/api/v1/labs") +@PreAuthorize("isAuthenticated()") class LabController( private val labService: LabService, + private val accessService: CourseAccessService, ) { + /** + * Без `courseId` список ограничен доступными курсами (admin видит все). + * С `courseId` доступ к курсу проверяется явно. + */ @GetMapping fun list( @RequestParam(required = false) courseId: UUID?, - ): List = labService.list(courseId) + @AuthenticationPrincipal jwt: Jwt, + ): List { + 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}") fun detail( @PathVariable id: UUID, - ): LabDetail = labService.detail(id) + @AuthenticationPrincipal jwt: Jwt, + ): LabDetail { + accessService.requireLabAccess(id, jwt) + return labService.detail(id) + } @PostMapping("/{id}/copy") @PreAuthorize("hasAuthority('ROLE_ADMIN')") diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt new file mode 100644 index 0000000..8883959 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/course/CourseAccessService.kt @@ -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? = + 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 { + 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): Set { + 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" + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt new file mode 100644 index 0000000..779f36a --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/identity/IdentityClient.kt @@ -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 = + 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>() {} + +/** Таймауты обязательны: зависший 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() diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt index 4fee18a..4543e99 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt @@ -103,23 +103,16 @@ class LabService( @Transactional(readOnly = true) fun list(courseId: UUID? = null): List { val labList = if (courseId == null) labs.findAllByOrderByNumberAsc() else labs.findAllByCourseIdOrderByNumberAsc(courseId) - return labList.map { lab -> - val variants = variantsRepo.findByLabIdOrderByNumber(lab.id) - LabSummary( - id = lab.id.toString(), - number = lab.number, - title = lab.title, - status = lab.status, - semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—", - deadlineAt = lab.deadlineAt?.toString(), - defenseAt = lab.defenseAt?.toString(), - pointsMax = lab.pointsMax, - variantCount = variants.size, - courseId = lab.course?.id?.toString(), - courseNo = lab.course?.courseNo, - courseTitle = lab.course?.title, - ) - } + return labList.map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) } + } + + /** Список лабораторных только по доступным курсам (курсовая область видимости). */ + @Transactional(readOnly = true) + fun list(courseIds: Collection): List { + if (courseIds.isEmpty()) return emptyList() + return labs + .findAllByCourseIdInOrderByNumberAsc(courseIds) + .map { it.toSummary(variantsRepo.findByLabIdOrderByNumber(it.id)) } } @Transactional(readOnly = true) @@ -399,14 +392,6 @@ class LabService( } 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). */ @@ -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 Lab.toSummary(variants: List): 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() +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt index 7bdf9d8..f260536 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/CourseRepositories.kt @@ -20,6 +20,8 @@ interface CourseTeacherRepository : JpaRepository { accountId: UUID, ): CourseTeacher? + fun findByAccountIdAndRemovedAtIsNull(accountId: UUID): List + fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long } @@ -31,5 +33,7 @@ interface CourseGroupRepository : JpaRepository { groupId: UUID, ): CourseGroup? + fun findByGroupIdInAndRemovedAtIsNull(groupIds: Collection): List + fun countByCourseIdAndRemovedAtIsNull(courseId: UUID): Long } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt index 3579cd4..4278981 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/LabRepository.kt @@ -9,6 +9,8 @@ interface LabRepository : JpaRepository { fun findAllByCourseIdOrderByNumberAsc(courseId: UUID): List + fun findAllByCourseIdInOrderByNumberAsc(courseIds: Collection): List + fun existsByNumberAndCourseId( number: Int, courseId: UUID, diff --git a/services/academic-service/src/main/resources/application.yml b/services/academic-service/src/main/resources/application.yml index a9d57bf..ae8dc01 100644 --- a/services/academic-service/src/main/resources/application.yml +++ b/services/academic-service/src/main/resources/application.yml @@ -43,3 +43,5 @@ springdoc: lms: content: assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets} + identity: + base-url: ${LMS_IDENTITY_URI:http://localhost:8081} diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceTest.kt new file mode 100644 index 0000000..198dd17 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/course/CourseAccessServiceTest.kt @@ -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() + private val teachers = mockk() + private val groups = mockk() + private val labs = mockk() + private val identity = mockk() + private val courseService = mockk() + + 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" + } +} diff --git a/services/gateway/src/main/resources/application.yml b/services/gateway/src/main/resources/application.yml index f72529d..5c9c566 100644 --- a/services/gateway/src/main/resources/application.yml +++ b/services/gateway/src/main/resources/application.yml @@ -12,7 +12,7 @@ spring: - id: identity uri: ${LMS_IDENTITY_URI:http://localhost:8081} 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 uri: ${LMS_ACADEMIC_URI:http://localhost:8082} predicates: