diff --git a/assets/vue/services/attendanceService.js b/assets/vue/services/attendanceService.js index dd2ab86ff6b..d878ae1bd1b 100644 --- a/assets/vue/services/attendanceService.js +++ b/assets/vue/services/attendanceService.js @@ -47,8 +47,12 @@ export default { }, /** - * Creates a new attendance list. - * @param {Object} data - Data for the new attendance list. + * Creates a new attendance list. The course context is mirrored from the + * request body to the query string so CidReqListener can resolve it and + * CourseContextRoleListener can publish the contextual TEACHER role that + * the operation requires. + * + * @param {Object} data - Data for the new attendance list (includes cid, sid). * @returns {Promise} - Data of the created attendance list. */ createAttendance: async (data) => { @@ -66,8 +70,13 @@ export default { }, /** - * Deletes an attendance list. + * Deletes an attendance list. The course context (cid, optional sid/gid) + * must be supplied as query parameters so CidReqListener can resolve the + * course and CourseContextRoleListener can publish the contextual TEACHER + * role that the operation requires. + * * @param {Number|String} attendanceId - ID of the attendance list. + * @param {{cid: Number|String, sid?: Number|String, gid?: Number|String}} context * @returns {Promise} - Result of the deletion. */ deleteAttendance: async (attendanceId) => { diff --git a/src/CoreBundle/Controller/Api/CreateCBlogAction.php b/src/CoreBundle/Controller/Api/CreateCBlogAction.php index d3917962645..6300027fc6d 100644 --- a/src/CoreBundle/Controller/Api/CreateCBlogAction.php +++ b/src/CoreBundle/Controller/Api/CreateCBlogAction.php @@ -16,6 +16,7 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; #[AsController] class CreateCBlogAction extends BaseResourceFileAction @@ -42,6 +43,12 @@ public function __invoke( $resourceLinkList = \is_array($resourceLinkListRaw) ? $resourceLinkListRaw : []; } + // The `cid` (and optional `sid`/`gid`) query parameter establishes the + // course context that gated the security expression. Any + // resourceLinkList entry that points to a different context would + // bypass that gate, so we reject the request outright. + $this->assertResourceLinkListMatchesQueryContext($request, $resourceLinkList, $security); + $blog = (new CBlog()) ->setTitle($title) ->setBlogSubtitle($subtitle) @@ -89,4 +96,47 @@ private function handleShortcutCreation( $shortcutRepository->addShortCut($blog, $currentUser, $course, $session); } } + + /** + * @param array $resourceLinkList + */ + private function assertResourceLinkListMatchesQueryContext( + Request $request, + array $resourceLinkList, + Security $security, + ): void { + if ($security->isGranted('ROLE_ADMIN')) { + return; + } + + if ([] === $resourceLinkList) { + return; + } + + $queryCid = (int) $request->query->get('cid'); + $querySid = (int) $request->query->get('sid'); + $queryGid = (int) $request->query->get('gid'); + + foreach ($resourceLinkList as $entry) { + if (!\is_array($entry)) { + continue; + } + + $entryCid = (int) ($entry['cid'] ?? 0); + $entrySid = (int) ($entry['sid'] ?? 0); + $entryGid = (int) ($entry['gid'] ?? 0); + + if ($entryCid > 0 && $entryCid !== $queryCid) { + throw new AccessDeniedHttpException('resourceLinkList course does not match the request context.'); + } + + if ($entrySid > 0 && $entrySid !== $querySid) { + throw new AccessDeniedHttpException('resourceLinkList session does not match the request context.'); + } + + if ($entryGid > 0 && $entryGid !== $queryGid) { + throw new AccessDeniedHttpException('resourceLinkList group does not match the request context.'); + } + } + } } diff --git a/src/CoreBundle/Controller/Api/CreateDocumentFileAction.php b/src/CoreBundle/Controller/Api/CreateDocumentFileAction.php index 6f2c8a13e21..d3f56dea9ef 100644 --- a/src/CoreBundle/Controller/Api/CreateDocumentFileAction.php +++ b/src/CoreBundle/Controller/Api/CreateDocumentFileAction.php @@ -6,13 +6,16 @@ namespace Chamilo\CoreBundle\Controller\Api; +use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Helpers\AiDisclosureHelper; use Chamilo\CoreBundle\Helpers\CourseHelper; use Chamilo\CoreBundle\Repository\Node\CourseRepository; use Chamilo\CourseBundle\Entity\CDocument; use Chamilo\CourseBundle\Repository\CDocumentRepository; use Doctrine\ORM\EntityManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -48,7 +51,14 @@ public function __invoke( CourseRepository $courseRepository, CourseHelper $courseHelper, AiDisclosureHelper $aiDisclosureHelper, + Security $security, ): CDocument { + // Reject any resourceLinkList entry pointing to a course where the + // caller is not a teacher (admins bypass). The operation-level + // security expression only validates the role for the query `cid`, + // so the body could otherwise target a foreign course (IDOR). + $this->assertUserCanLinkToCourses($request, $security, $courseRepository); + $isUncompressZipEnabled = (string) $request->get('isUncompressZipEnabled', 'false'); $fileExistsOption = (string) $request->get('fileExistsOption', 'rename'); $aiAssistedRaw = strtolower(trim((string) $request->get('ai_assisted', ''))); @@ -153,4 +163,65 @@ private function isAllowedCloudLinkHost(string $host): bool return false; } + + private function assertUserCanLinkToCourses( + Request $request, + Security $security, + CourseRepository $courseRepository, + ): void { + $user = $security->getUser(); + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + if ($security->isGranted('ROLE_ADMIN')) { + return; + } + + $resourceLinkList = $this->extractResourceLinkList($request); + foreach ($resourceLinkList as $entry) { + if (!\is_array($entry)) { + continue; + } + + $cid = (int) ($entry['cid'] ?? 0); + if ($cid <= 0) { + continue; + } + + $course = $courseRepository->find($cid); + if (null === $course || !$course->hasUserAsTeacher($user)) { + throw new AccessDeniedHttpException('You are not a teacher of one of the referenced courses.'); + } + } + } + + /** + * @return array + */ + private function extractResourceLinkList(Request $request): array + { + $raw = (string) $request->getContent(); + if ('' !== $raw) { + $decoded = json_decode($raw, true); + if (\is_array($decoded) && isset($decoded['resourceLinkList']) && \is_array($decoded['resourceLinkList'])) { + return $decoded['resourceLinkList']; + } + } + + $fromForm = $request->get('resourceLinkList', []); + if (\is_array($fromForm)) { + return $fromForm; + } + + if (\is_string($fromForm) && '' !== $fromForm) { + $normalized = str_contains($fromForm, '[') ? $fromForm : '['.$fromForm.']'; + $decoded = json_decode($normalized, true); + if (\is_array($decoded)) { + return $decoded; + } + } + + return []; + } } diff --git a/src/CoreBundle/EventListener/CourseContextRoleListener.php b/src/CoreBundle/EventListener/CourseContextRoleListener.php new file mode 100644 index 00000000000..860bf4cc9cb --- /dev/null +++ b/src/CoreBundle/EventListener/CourseContextRoleListener.php @@ -0,0 +1,111 @@ +getRoles() continues to see them. + * - The security token's roleNames, so expressions such as + * `security: "is_granted('ROLE_CURRENT_COURSE_STUDENT')"` on + * API Platform operations resolve correctly (AbstractToken::getRoleNames() + * freezes the role list at token-creation time). + * + * Must run with a kernel.request priority lower than CidReqListener (priority 6) + * so the course/session/group is already in the session by the time we look it up. + */ +final class CourseContextRoleListener +{ + public function __construct( + private readonly TokenStorageInterface $tokenStorage, + private readonly CourseAccessResolver $resolver, + ) {} + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + if (!$request->hasSession()) { + return; + } + + $token = $this->tokenStorage->getToken(); + if (null === $token) { + return; + } + + $user = $token->getUser(); + if (!$user instanceof User) { + return; + } + + $sessionHandler = $request->getSession(); + $course = $sessionHandler->get('course'); + $courseSession = $sessionHandler->get('session'); + $group = $sessionHandler->get('group'); + + $contextRoles = []; + if ($course instanceof Course) { + $contextRoles = $this->resolver->resolveCourseRoles( + $user, + $course, + $courseSession instanceof Session ? $courseSession : null, + ); + + if ($group instanceof CGroup) { + $contextRoles = array_merge( + $contextRoles, + $this->resolver->resolveGroupRoles($user, $course, $group), + ); + } + } + + $contextRoles = array_values(array_unique($contextRoles)); + + $existingRoleNames = $token->getRoleNames(); + $hasStaleContextRoles = [] !== array_intersect($existingRoleNames, User::CONTEXT_ROLES); + + // Nothing to sync: no current context and no stale roles to strip. + if ([] === $contextRoles && !$hasStaleContextRoles) { + return; + } + + // Mirror the context roles into the User's temporary roles so that any + // legacy code reading $user->getRoles() observes the same state. + foreach ($contextRoles as $role) { + $user->addTemporaryRole($role); + } + + $persistedRoleNames = array_values(array_diff($existingRoleNames, User::CONTEXT_ROLES)); + $desiredRoleNames = array_values(array_unique(array_merge($persistedRoleNames, $contextRoles))); + + $firewallName = method_exists($token, 'getFirewallName') + ? (string) $token->getFirewallName() + : 'main'; + + if ('' === $firewallName) { + $firewallName = 'main'; + } + + $this->tokenStorage->setToken(new PostAuthenticationToken($user, $firewallName, $desiredRoleNames)); + } +} diff --git a/src/CoreBundle/Filter/CidFilter.php b/src/CoreBundle/Filter/CidFilter.php index ac21bda8b97..6ca02f858fd 100644 --- a/src/CoreBundle/Filter/CidFilter.php +++ b/src/CoreBundle/Filter/CidFilter.php @@ -41,8 +41,8 @@ public function getDescription(string $resourceClass): array return [ 'cid' => [ 'property' => null, - 'type' => 'int', - 'required' => false, + 'type' => 'string', + 'required' => $this->isRequired(), 'description' => 'Course identifier', ], ]; @@ -76,4 +76,9 @@ protected function filterProperty( ->setParameter('course', $course->getId()) ; } + + public function isRequired(): bool + { + return true; + } } diff --git a/src/CoreBundle/Filter/OptionalCourseLinkFilter.php b/src/CoreBundle/Filter/OptionalCourseLinkFilter.php new file mode 100644 index 00000000000..f297c416877 --- /dev/null +++ b/src/CoreBundle/Filter/OptionalCourseLinkFilter.php @@ -0,0 +1,15 @@ +getUser(); + $user = $token->getUser(); // Admins have access to everything. if ($this->security->isGranted('ROLE_ADMIN')) { @@ -76,116 +76,73 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ $request = $this->requestStack->getCurrentRequest(); $sessionId = $request?->query?->get('sid'); - $sessionRepository = $this->entityManager->getRepository(Session::class); $session = null; if (!empty($sessionId)) { /** @var Session|null $session */ - $session = $sessionRepository->find($sessionId); + $session = $this->entityManager->getRepository(Session::class)->find($sessionId); } switch ($attribute) { case self::VIEW: - // Course is hidden then is not visible for nobody expect admins. + // Course is hidden, so it is not visible for anyone except admins. if ($course->isHidden()) { return false; } // Course::OPEN_WORLD if ($course->isPublic()) { - if ($tokenUser instanceof User) { - $user = $this->getTokenSafeUser($token, $tokenUser); - if ($this->isStudent($user, $course, $session)) { - if ($this->isCourseLockedForUser($user, $course, $session?->getId() ?? 0)) { - throw new NotAllowedException($this->translator->trans('This course is locked. You must complete the prerequisite(s) first.'), 'warning', 403); - } - } - - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_STUDENT); - if ($course->hasUserAsTeacher($user)) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_TEACHER); - } - - $token->setUser($user); + if ($user instanceof User + && $this->isStudent($user, $course, $session) + && $this->isCourseLockedForUser($user, $course, $session?->getId() ?? 0) + ) { + throw new NotAllowedException($this->translator->trans('This course is locked. You must complete the prerequisite(s) first.'), 'warning', 403); } return true; } - // User should be instance of UserInterface. - if (!$tokenUser instanceof UserInterface) { + if (!$user instanceof UserInterface) { return false; } // Course::OPEN_PLATFORM - if (Course::OPEN_PLATFORM === $course->getVisibility()) { - if (false === $this->isOpenCourseAccessBlockedForRegisteredUsers()) { - if ($tokenUser instanceof User) { - $user = $this->getTokenSafeUser($token, $tokenUser); - if ($this->isStudent($user, $course, $session)) { - if ($this->isCourseLockedForUser($user, $course, $session?->getId() ?? 0)) { - throw new NotAllowedException($this->translator->trans('This course is locked. You must complete the prerequisite(s) first.'), 'warning', 403); - } - } - - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_STUDENT); - - if ($course->hasUserAsTeacher($user)) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_TEACHER); - } - - $token->setUser($user); - } - - return true; + if (Course::OPEN_PLATFORM === $course->getVisibility() + && false === $this->isOpenCourseAccessBlockedForRegisteredUsers() + ) { + if ($user instanceof User + && $this->isStudent($user, $course, $session) + && $this->isCourseLockedForUser($user, $course, $session?->getId() ?? 0) + ) { + throw new NotAllowedException($this->translator->trans('This course is locked. You must complete the prerequisite(s) first.'), 'warning', 403); } - } - - // Validation in session - if ($session && $tokenUser instanceof User) { - $user = $this->getTokenSafeUser($token, $tokenUser); - - $userIsGeneralCoach = $session->hasUserAsGeneralCoach($user); - $userIsCourseCoach = $session->hasCourseCoachInCourse($user, $course); - $userIsStudent = $session->hasUserInCourse($user, $course, Session::STUDENT); - if ($userIsGeneralCoach || $userIsCourseCoach) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_TEACHER); - - $token->setUser($user); + return true; + } + // Session-based access. + if (null !== $session && $user instanceof User) { + if ($session->hasUserAsGeneralCoach($user) + || $session->hasCourseCoachInCourse($user, $course) + ) { return true; } - if ($userIsStudent) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_STUDENT); - + if ($session->hasUserInCourse($user, $course, Session::STUDENT)) { if ($this->isCourseLockedForUser($user, $course, $session->getId())) { throw new NotAllowedException($this->translator->trans('This course is locked. You must complete the prerequisite(s) first.'), 'warning', 403); } - $token->setUser($user); - return true; } } // Course::REGISTERED - if ($tokenUser instanceof User && $course->hasSubscriptionByUser($tokenUser)) { - $user = $this->getTokenSafeUser($token, $tokenUser); - - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_STUDENT); - - if ($course->hasUserAsTeacher($user)) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_TEACHER); - } - + if ($user instanceof User && $course->hasSubscriptionByUser($user)) { if ($this->isCourseLockedForUser($user, $course)) { throw new NotAllowedException($this->translator->trans('This course is locked. You must complete the prerequisite(s) first.'), 'warning', 403); } - $token->setUser($user); - return true; } @@ -193,38 +150,12 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ case self::EDIT: case self::DELETE: - if ($tokenUser instanceof User && $course->hasUserAsTeacher($tokenUser)) { - $user = $this->getTokenSafeUser($token, $tokenUser); - - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_TEACHER); - - $token->setUser($user); - - return true; - } - - return false; + return $user instanceof User && $course->hasUserAsTeacher($user); } return false; } - /** - * Returns a "token-safe" User instance to add context roles without persisting them to DB. - * - * If the User is managed by Doctrine, we clone it, add roles to the clone, - * and store the clone in the token. - */ - private function getTokenSafeUser(TokenInterface $token, User $user): User - { - if ($this->entityManager->contains($user)) { - $user = clone $user; - $token->setUser($user); - } - - return $user; - } - /** * Checks whether registered users must be subscribed before accessing * OPEN_PLATFORM course contents. diff --git a/src/CoreBundle/Security/Authorization/Voter/GroupVoter.php b/src/CoreBundle/Security/Authorization/Voter/GroupVoter.php index 2df552e8a23..9e5c1984c39 100644 --- a/src/CoreBundle/Security/Authorization/Voter/GroupVoter.php +++ b/src/CoreBundle/Security/Authorization/Voter/GroupVoter.php @@ -93,8 +93,6 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ } if ($course->hasUserAsTeacher($user)) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_GROUP_TEACHER); - return true; } @@ -105,8 +103,6 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ switch ($attribute) { case self::VIEW: if ($isTutor) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_GROUP_TEACHER); - return true; } @@ -116,10 +112,6 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ $userIsInGroup = $group->hasMember($user); - if ($userIsInGroup) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_GROUP_STUDENT); - } - $requestUri = ''; // Check if user has access in legacy tool. $request = $this->requestStack->getCurrentRequest(); @@ -177,8 +169,6 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ case self::EDIT: case self::DELETE: if ($isTutor) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_GROUP_TEACHER); - return true; } diff --git a/src/CoreBundle/Security/Authorization/Voter/SessionVoter.php b/src/CoreBundle/Security/Authorization/Voter/SessionVoter.php index af9a072d52c..37d1725d137 100644 --- a/src/CoreBundle/Security/Authorization/Voter/SessionVoter.php +++ b/src/CoreBundle/Security/Authorization/Voter/SessionVoter.php @@ -98,35 +98,14 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ return false; } - if ($userIsGeneralCoach || $userIsCourseCoach) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_TEACHER); - } elseif ($userIsStudent) { // Student access. - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_STUDENT); - } - - if ( - ($userIsGeneralCoach || $userIsCourseCoach || $userIsStudent) - && Session::INVISIBLE != $visibilityForUser - ) { - return true; - } - - return false; + return ($userIsGeneralCoach || $userIsCourseCoach || $userIsStudent) + && Session::INVISIBLE != $visibilityForUser; case self::EDIT: case self::DELETE: - $canEdit = $this->canEditSession($user, $session, false); - - if ($canEdit) { - $user->addRole(ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_TEACHER); - - return true; - } - - return false; + return $this->canEditSession($user, $session, false); } - // User don't have access to the session return false; } diff --git a/src/CoreBundle/Security/CourseAccessResolver.php b/src/CoreBundle/Security/CourseAccessResolver.php new file mode 100644 index 00000000000..29b111f7beb --- /dev/null +++ b/src/CoreBundle/Security/CourseAccessResolver.php @@ -0,0 +1,120 @@ + ROLE_CURRENT_COURSE_* role names the user holds in the given course/session + */ + public function resolveCourseRoles(User $user, Course $course, ?Session $session = null): array + { + if ($course->isHidden()) { + return []; + } + + if ($course->isPublic()) { + return $this->courseRolesForAccessibleCourse($user, $course); + } + + if (Course::OPEN_PLATFORM === $course->getVisibility() + && false === $this->isOpenCourseAccessBlockedForRegisteredUsers() + ) { + return $this->courseRolesForAccessibleCourse($user, $course); + } + + if (null !== $session) { + if ($session->hasUserAsGeneralCoach($user) + || $session->hasCourseCoachInCourse($user, $course) + ) { + return [ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_TEACHER]; + } + + if ($session->hasUserInCourse($user, $course, Session::STUDENT)) { + return [ResourceNodeVoter::ROLE_CURRENT_COURSE_SESSION_STUDENT]; + } + } + + if (Course::REGISTERED === $course->getVisibility() + && $course->hasSubscriptionByUser($user) + ) { + return $this->courseRolesForAccessibleCourse($user, $course); + } + + return []; + } + + /** + * @return array ROLE_CURRENT_COURSE_GROUP_* role names the user holds in the given group + */ + public function resolveGroupRoles(User $user, Course $course, CGroup $group): array + { + if ($course->isHidden()) { + return []; + } + + if (Course::REGISTERED === $course->getVisibility() + && false === $course->hasSubscriptionByUser($user) + ) { + return []; + } + + if ($course->hasUserAsTeacher($user) || $group->hasTutor($user)) { + return [ResourceNodeVoter::ROLE_CURRENT_COURSE_GROUP_TEACHER]; + } + + if ($group->hasMember($user)) { + return [ResourceNodeVoter::ROLE_CURRENT_COURSE_GROUP_STUDENT]; + } + + return []; + } + + /** + * @return array + */ + private function courseRolesForAccessibleCourse(User $user, Course $course): array + { + $roles = [ResourceNodeVoter::ROLE_CURRENT_COURSE_STUDENT]; + + if ($course->hasUserAsTeacher($user)) { + $roles[] = ResourceNodeVoter::ROLE_CURRENT_COURSE_TEACHER; + } + + return $roles; + } + + private function isOpenCourseAccessBlockedForRegisteredUsers(): bool + { + return filter_var( + $this->settingsManager->getSetting('course.block_registered_users_access_to_open_course_contents', true), + FILTER_VALIDATE_BOOLEAN, + ); + } +} diff --git a/src/CoreBundle/State/DocumentCollectionStateProvider.php b/src/CoreBundle/State/DocumentCollectionStateProvider.php index cb7fc504793..ae8ae611364 100644 --- a/src/CoreBundle/State/DocumentCollectionStateProvider.php +++ b/src/CoreBundle/State/DocumentCollectionStateProvider.php @@ -17,6 +17,7 @@ use Chamilo\CoreBundle\Entity\Session; use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Helpers\AccessUrlHelper; +use Chamilo\CoreBundle\Helpers\CidReqHelper; use Chamilo\CoreBundle\Repository\ResourceLinkRepository; use Chamilo\CoreBundle\Settings\SettingsManager; use Chamilo\CourseBundle\Entity\CDocument; @@ -51,6 +52,7 @@ public function __construct( private readonly CacheInterface $documentListCache, #[Autowire('%kernel.secret%')] private readonly string $appSecret, + private readonly CidReqHelper $cidReqHelper, ) {} /** @@ -80,9 +82,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c ; $query = $request->query->all(); - $cid = (int) ($query['cid'] ?? 0); - $sid = (int) ($query['sid'] ?? 0); - $gid = (int) ($query['gid'] ?? 0); + $course = $this->cidReqHelper->getCourseEntity(); + $session = $this->cidReqHelper->getSessionEntity(); + $group = $this->cidReqHelper->getGroupEntity(); $page = max(1, (int) ($query['page'] ?? 1)); $itemsPerPage = (int) ($query['itemsPerPage'] ?? 20); @@ -93,11 +95,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $itemsPerPage = 5000; } - $hasContext = $cid > 0 || $sid > 0 || $gid > 0; + $hasContext = $course || $session || $group; // By default, documents must be visible in session context including base course content, // because CDocument implements ResourceShowCourseResourcesInSessionInterface. - $includeBaseContent = $sid > 0 + $includeBaseContent = $session && is_a(CDocument::class, ResourceShowCourseResourcesInSessionInterface::class, true); // Allow API clients to override behavior (withBaseContent=0/1). @@ -173,7 +175,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $hiddenSystemTypes[] = 'user_folder_ses'; } else { // When enabled, never show session-scoped shared folders in base course context. - if ($sid <= 0) { + if (!$session) { $hiddenSystemTypes[] = 'user_folder_ses'; } } @@ -230,11 +232,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c ; } - if ($cid > 0) { - $qb->andWhere('IDENTITY(rl.course) = :cid')->setParameter('cid', $cid); + if ($course) { + $qb->andWhere('IDENTITY(rl.course) = :cid')->setParameter('cid', $course->getId()); } - if ($sid > 0) { + if ($session) { if ($includeBaseContent) { // Include both session content and base course content. $qb @@ -245,12 +247,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c 'IDENTITY(rl.session) = 0' ) ) - ->setParameter('sid', $sid) + ->setParameter('sid', $session->getId()) ; } else { $qb ->andWhere('IDENTITY(rl.session) = :sid') - ->setParameter('sid', $sid) + ->setParameter('sid', $session->getId()) ; } } else { @@ -262,8 +264,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c ); } - if ($gid > 0) { - $qb->andWhere('IDENTITY(rl.group) = :gid')->setParameter('gid', $gid); + if ($group) { + $qb->andWhere('IDENTITY(rl.group) = :gid')->setParameter('gid', $group->getIid()); } else { $qb->andWhere('rl.group IS NULL'); } @@ -279,10 +281,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c /** @var ResourceLinkRepository $linkRepo */ $linkRepo = $this->entityManager->getRepository(ResourceLink::class); - $courseEntity = $cid > 0 ? $this->entityManager->getRepository(Course::class)->find($cid) : null; - $sessionEntity = $sid > 0 ? $this->entityManager->getRepository(Session::class)->find($sid) : null; - $groupEntity = $gid > 0 ? $this->entityManager->getRepository(CGroup::class)->find($gid) : null; - // Resolve possible folder links: // - session link (sid=current session) // - base link (sid=NULL), when base content is enabled in session view @@ -290,9 +288,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $sessionParentLink = $linkRepo->findParentLinkForContext( $folderNode, - $courseEntity, - $sessionEntity, - $groupEntity, + $course, + $session, + $group, null, null ); @@ -300,12 +298,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $parentLinkIds[] = (int) $sessionParentLink->getId(); } - if ($sid > 0 && $includeBaseContent && null !== $courseEntity) { + if ($session && $includeBaseContent && $course) { $baseParentLink = $linkRepo->findParentLinkForContext( $folderNode, - $courseEntity, + $course, null, - $groupEntity, + $group, null, null ); @@ -413,7 +411,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c // - the access_url ID so that multi-portal setups within one installation // are also isolated. $accessUrlId = $this->accessUrlHelper->getCurrent()?->getId() ?? 1; - $viewerProfileBucket = $this->getViewerProfileCacheBucket($cid, $sid); + $viewerProfileBucket = $this->getViewerProfileCacheBucket($course, $session); $sortedTypes = $effectiveFiletypes; sort($sortedTypes); $sortedHidden = $hiddenSystemTypes; @@ -421,9 +419,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $cacheKey = 'doc_list_'.$this->getInstallationPrefix().'_'.hash('md5', serialize([ $accessUrlId, $viewerProfileBucket, - $cid, - $sid, - $gid, + $course?->getId() ?? 0, + $session?->getId() ?? 0, + $group?->getIid() ?? 0, $parentNodeId, $loadNode, $sortedTypes, @@ -581,7 +579,7 @@ private function getInstallationPrefix(): string return $this->installationPrefix; } - private function getViewerProfileCacheBucket(int $cid, int $sid): string + private function getViewerProfileCacheBucket(?Course $course, ?Session $session): string { $user = $this->security->getUser(); @@ -593,10 +591,7 @@ private function getViewerProfileCacheBucket(int $cid, int $sid): string return 'admin'; } - $course = $cid > 0 ? $this->entityManager->getRepository(Course::class)->find($cid) : null; - $session = $sid > 0 ? $this->entityManager->getRepository(Session::class)->find($sid) : null; - - if ($session instanceof Session && $course instanceof Course) { + if ($session instanceof Session && $course) { $userIsGeneralCoach = $session->hasUserAsGeneralCoach($user); $userIsCourseCoach = $session->hasCourseCoachInCourse($user, $course); $userIsStudent = $session->hasUserInCourse($user, $course, Session::STUDENT); @@ -610,7 +605,7 @@ private function getViewerProfileCacheBucket(int $cid, int $sid): string } } - if ($course instanceof Course) { + if ($course) { if ($course->hasUserAsTeacher($user)) { return 'teacher'; } diff --git a/src/CourseBundle/Entity/CAttendance.php b/src/CourseBundle/Entity/CAttendance.php index 5035a24644a..5df72b61226 100644 --- a/src/CourseBundle/Entity/CAttendance.php +++ b/src/CourseBundle/Entity/CAttendance.php @@ -18,6 +18,7 @@ use ApiPlatform\OpenApi\Model\Parameter; use Chamilo\CoreBundle\Entity\AbstractResource; use Chamilo\CoreBundle\Entity\ResourceInterface; +use Chamilo\CoreBundle\Filter\CidFilter; use Chamilo\CoreBundle\Filter\SidFilter; use Chamilo\CoreBundle\State\CAttendanceStateProcessor; use Chamilo\CourseBundle\Repository\CAttendanceRepository; @@ -50,13 +51,16 @@ name: 'soft_delete', processor: CAttendanceStateProcessor::class ), - new Delete(security: "is_granted('ROLE_TEACHER')"), + new Delete( + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", + ), new Post( uriTemplate: '/attendances/{iid}/calendars', openapi: new Operation( summary: 'Add a calendar to an attendance.' ), denormalizationContext: ['groups' => ['attendance:write']], + security: "is_granted('EDIT', object.resourceNode)", name: 'calendar_add', processor: CAttendanceStateProcessor::class ), @@ -72,16 +76,17 @@ ), ], ), + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), - new Get(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('VIEW', object.resourceNode)"), new Post( denormalizationContext: ['groups' => ['attendance:write']], - security: "is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['Default']] ), new Put( denormalizationContext: ['groups' => ['attendance:write']], - security: "is_granted('ROLE_TEACHER')" + security: "is_granted('EDIT', object.resourceNode)" ), ], normalizationContext: [ @@ -92,6 +97,7 @@ paginationEnabled: true, )] #[ApiFilter(SearchFilter::class, properties: ['active' => 'exact', 'title' => 'partial', 'resourceNode.parent' => 'exact'])] +#[ApiFilter(filterClass: CidFilter::class)] #[ApiFilter(filterClass: SidFilter::class)] #[ORM\Table(name: 'c_attendance')] #[ORM\Index(columns: ['active'], name: 'active')] diff --git a/src/CourseBundle/Entity/CBlog.php b/src/CourseBundle/Entity/CBlog.php index 48e46ae0101..13316d63f6d 100644 --- a/src/CourseBundle/Entity/CBlog.php +++ b/src/CourseBundle/Entity/CBlog.php @@ -37,7 +37,9 @@ #[ORM\Table(name: 'c_blog')] #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection( + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", + ), new Post( controller: CreateCBlogAction::class, openapi: new Operation( @@ -65,7 +67,7 @@ ]), ), ), - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['Default', 'blog:write']], deserialize: false ), diff --git a/src/CourseBundle/Entity/CBlogAttachment.php b/src/CourseBundle/Entity/CBlogAttachment.php index e1031394cca..6547961d699 100644 --- a/src/CourseBundle/Entity/CBlogAttachment.php +++ b/src/CourseBundle/Entity/CBlogAttachment.php @@ -18,13 +18,13 @@ #[ORM\Entity] #[ApiResource( operations: [ - new Get(security: "is_granted('ROLE_USER')"), - new GetCollection(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_USER')"), + new Get(security: "object.getBlog() != null and is_granted('VIEW', object.getBlog().resourceNode)"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"), + new Post(securityPostDenormalize: "object.getBlog() != null and is_granted('EDIT', object.getBlog().resourceNode)"), new Post( uriTemplate: '/c_blog_attachments/upload', controller: CreateBlogAttachmentAction::class, - security: "is_granted('IS_AUTHENTICATED_REMEMBERED')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", output: false, deserialize: false ), diff --git a/src/CourseBundle/Entity/CBlogComment.php b/src/CourseBundle/Entity/CBlogComment.php index d359dafd6b7..84c47769e5f 100644 --- a/src/CourseBundle/Entity/CBlogComment.php +++ b/src/CourseBundle/Entity/CBlogComment.php @@ -24,7 +24,7 @@ #[ApiResource( operations: [ new Get(security: "(object.getBlog() != null and is_granted('VIEW', object.getBlog().resourceNode)) or (object.getPost() != null and object.getPost().getBlog() != null and is_granted('VIEW', object.getPost().getBlog().resourceNode))"), - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"), new Post( securityPostDenormalize: "(object.getPost() != null and object.getPost().getBlog() != null and is_granted('VIEW', object.getPost().getBlog().resourceNode)) or (object.getBlog() != null and is_granted('VIEW', object.getBlog().resourceNode))", processor: CBlogAssignAuthorProcessor::class diff --git a/src/CourseBundle/Entity/CBlogPost.php b/src/CourseBundle/Entity/CBlogPost.php index 3a7c48435c3..f5de92cd13e 100644 --- a/src/CourseBundle/Entity/CBlogPost.php +++ b/src/CourseBundle/Entity/CBlogPost.php @@ -24,7 +24,7 @@ #[ApiResource( operations: [ new Get(security: "object.getBlog() != null and is_granted('VIEW', object.getBlog().resourceNode)"), - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"), new Post( securityPostDenormalize: "object.getBlog() != null and is_granted('EDIT', object.getBlog().resourceNode)", processor: CBlogAssignAuthorProcessor::class diff --git a/src/CourseBundle/Entity/CBlogRating.php b/src/CourseBundle/Entity/CBlogRating.php index 3485f15a4de..fa561c9b3df 100644 --- a/src/CourseBundle/Entity/CBlogRating.php +++ b/src/CourseBundle/Entity/CBlogRating.php @@ -19,7 +19,7 @@ normalizationContext: ['groups' => ['blog_rating:read']], denormalizationContext: ['groups' => ['blog_rating:write']], paginationEnabled: true, - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", )] #[ApiFilter(SearchFilter::class, properties: [ 'blog' => 'exact', diff --git a/src/CourseBundle/Entity/CBlogRelUser.php b/src/CourseBundle/Entity/CBlogRelUser.php index 42c4e1d89c7..93a6b939bf2 100644 --- a/src/CourseBundle/Entity/CBlogRelUser.php +++ b/src/CourseBundle/Entity/CBlogRelUser.php @@ -18,7 +18,7 @@ #[ApiResource( operations: [ - new GetCollection(security: "is_granted('ROLE_USER')"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"), new Post(securityPostDenormalize: "object.getBlog() != null and is_granted('EDIT', object.getBlog().resourceNode)"), new Delete(security: "object.getBlog() != null and is_granted('EDIT', object.getBlog().resourceNode)"), ], diff --git a/src/CourseBundle/Entity/CBlogTask.php b/src/CourseBundle/Entity/CBlogTask.php index 1aa64b34d99..061db218e0a 100644 --- a/src/CourseBundle/Entity/CBlogTask.php +++ b/src/CourseBundle/Entity/CBlogTask.php @@ -21,14 +21,14 @@ #[ORM\Entity] #[ApiResource( operations: [ - new Get(security: "is_granted('ROLE_USER')"), - new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "object.getBlog() != null and is_granted('VIEW', object.getBlog().resourceNode)"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"), new Post( - security: "is_granted('ROLE_USER')", + securityPostDenormalize: "object.getBlog() != null and is_granted('EDIT', object.getBlog().resourceNode)", processor: CBlogAssignAuthorProcessor::class ), - new Patch(security: "object.getAuthor() === user or is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_TEACHER') or is_granted('ROLE_ADMIN')"), - new Delete(security: "object.getAuthor() === user or is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_TEACHER') or is_granted('ROLE_ADMIN')"), + new Patch(security: "object.getBlog() != null and (is_granted('EDIT', object.getBlog().resourceNode) or (object.getAuthor() === user and is_granted('VIEW', object.getBlog().resourceNode)))"), + new Delete(security: "object.getBlog() != null and (is_granted('DELETE', object.getBlog().resourceNode) or (object.getAuthor() === user and is_granted('VIEW', object.getBlog().resourceNode)))"), ], normalizationContext: ['groups' => ['blog_task:read']], denormalizationContext: ['groups' => ['blog_task:write']] diff --git a/src/CourseBundle/Entity/CBlogTaskRelUser.php b/src/CourseBundle/Entity/CBlogTaskRelUser.php index 79ceaeac6be..0cd8b4da830 100644 --- a/src/CourseBundle/Entity/CBlogTaskRelUser.php +++ b/src/CourseBundle/Entity/CBlogTaskRelUser.php @@ -22,14 +22,13 @@ #[ORM\Entity] #[ApiResource( operations: [ - new Get(security: "is_granted('ROLE_USER')"), - new GetCollection(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_TEACHER')"), + new Get(security: "object.getBlog() != null and is_granted('VIEW', object.getBlog().resourceNode)"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"), + new Post(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')"), new Patch(security: " object.getUser() === user or is_granted('ROLE_CURRENT_COURSE_TEACHER') - or is_granted('ROLE_TEACHER') - or is_granted('ROLE_ADMIN') + or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') "), ], normalizationContext: ['groups' => ['task_rel_user:read']], diff --git a/src/CourseBundle/Entity/CCalendarEvent.php b/src/CourseBundle/Entity/CCalendarEvent.php index 248fd41acd9..d95f0bc7c33 100644 --- a/src/CourseBundle/Entity/CCalendarEvent.php +++ b/src/CourseBundle/Entity/CCalendarEvent.php @@ -24,8 +24,8 @@ use Chamilo\CoreBundle\Entity\ResourceInterface; use Chamilo\CoreBundle\Entity\ResourceRestrictToGroupContextInterface; use Chamilo\CoreBundle\Entity\Room; -use Chamilo\CoreBundle\Filter\CidFilter; use Chamilo\CoreBundle\Filter\GlobalEventFilter; +use Chamilo\CoreBundle\Filter\OptionalCourseLinkFilter; use Chamilo\CoreBundle\Filter\SidFilter; use Chamilo\CoreBundle\State\CalendarEventStateProvider; use Chamilo\CoreBundle\State\CCalendarEventStateProcessor; @@ -73,7 +73,7 @@ #[ORM\Entity(repositoryClass: CCalendarEventRepository::class)] #[ApiFilter(filterClass: SearchFilter::class, properties: ['allDay' => 'boolean'])] #[ApiFilter(filterClass: DateFilter::class, strategy: 'exclude_null')] -#[ApiFilter(filterClass: CidFilter::class)] +#[ApiFilter(filterClass: OptionalCourseLinkFilter::class)] #[ApiFilter(filterClass: SidFilter::class)] #[ApiFilter(GlobalEventFilter::class, properties: ['type'])] class CCalendarEvent extends AbstractResource implements ResourceInterface, ResourceRestrictToGroupContextInterface, Stringable diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php index f966261d176..d50099d3abb 100644 --- a/src/CourseBundle/Entity/CDocument.php +++ b/src/CourseBundle/Entity/CDocument.php @@ -98,7 +98,7 @@ ]), ), ), - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('EDIT', object.resourceNode)", validationContext: ['groups' => ['Default', 'media_object_create', 'document:write']], deserialize: false ), @@ -210,7 +210,7 @@ ]), ), ), - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), new Post( uriTemplate: '/documents/download-all', @@ -245,26 +245,12 @@ ]), ), ), - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", deserialize: false ), new GetCollection( - parameters: [ - 'cid' => new QueryParameter( - required: true, - schema: ['type' => 'integer'], - description: 'Course identifier', - ), - ], openapi: new Operation( parameters: [ - new Parameter( - name: 'cid', - in: 'query', - description: 'Course identifier', - required: true, - schema: ['type' => 'integer'], - ), new Parameter( name: 'resourceNode.parent', in: 'query', @@ -274,7 +260,7 @@ ), ], ), - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", provider: DocumentCollectionStateProvider::class, ), new Get( diff --git a/src/CourseBundle/Entity/CDropboxFile.php b/src/CourseBundle/Entity/CDropboxFile.php index d597e8dec68..8c5e68d83c2 100644 --- a/src/CourseBundle/Entity/CDropboxFile.php +++ b/src/CourseBundle/Entity/CDropboxFile.php @@ -23,7 +23,7 @@ new Post( uriTemplate: '/c_dropbox_files/upload', controller: CreateDropboxFileAction::class, - security: "is_granted('IS_AUTHENTICATED_REMEMBERED')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", validationContext: ['groups' => ['Default']], output: false, deserialize: false diff --git a/src/CourseBundle/Entity/CGlossary.php b/src/CourseBundle/Entity/CGlossary.php index 6292df7ced9..b054ce9785a 100644 --- a/src/CourseBundle/Entity/CGlossary.php +++ b/src/CourseBundle/Entity/CGlossary.php @@ -84,7 +84,7 @@ ]), ), ), - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['Default', 'media_object_create', 'glossary:write']], deserialize: false ), @@ -127,7 +127,8 @@ ], ), ], - ) + ), + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), new Post( uriTemplate: '/glossaries/import', @@ -155,21 +156,21 @@ ), ], ), - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['Default', 'media_object_create', 'glossary:write']], deserialize: false ), new Post( uriTemplate: '/glossaries/export', controller: ExportCGlossaryAction::class, - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['Default', 'media_object_create', 'glossary:write']], deserialize: false ), new Post( uriTemplate: '/glossaries/export_to_documents', controller: ExportGlossaryToDocumentsAction::class, - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['Default', 'media_object_create', 'glossary:write']], deserialize: false ), diff --git a/src/CourseBundle/Entity/CGroup.php b/src/CourseBundle/Entity/CGroup.php index 68b196ce3b0..cb9fa6a10b5 100644 --- a/src/CourseBundle/Entity/CGroup.php +++ b/src/CourseBundle/Entity/CGroup.php @@ -40,7 +40,8 @@ schema: ['type' => 'integer'], ), ], - ) + ), + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), new Get(security: "is_granted('VIEW', object.resourceNode)"), ], diff --git a/src/CourseBundle/Entity/CLink.php b/src/CourseBundle/Entity/CLink.php index e16b82746d7..18614998812 100644 --- a/src/CourseBundle/Entity/CLink.php +++ b/src/CourseBundle/Entity/CLink.php @@ -99,7 +99,7 @@ ]), ), ), - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['Default', 'media_object_create', 'link:write']], deserialize: false ), @@ -157,7 +157,7 @@ ]), ), ), - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", deserialize: false ), new Get(security: "is_granted('VIEW', object.resourceNode)"), @@ -204,7 +204,8 @@ schema: ['type' => 'integer'], ), ], - ) + ), + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), ], normalizationContext: [ diff --git a/src/CourseBundle/Entity/CLinkCategory.php b/src/CourseBundle/Entity/CLinkCategory.php index 0935e2da916..35c5dca14da 100644 --- a/src/CourseBundle/Entity/CLinkCategory.php +++ b/src/CourseBundle/Entity/CLinkCategory.php @@ -88,7 +88,7 @@ ]), ), ), - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['Default', 'media_object_create', 'link_category:write']], deserialize: false ), @@ -123,7 +123,8 @@ ], ), ], - ) + ), + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), ], normalizationContext: [ diff --git a/src/CourseBundle/Entity/CLp.php b/src/CourseBundle/Entity/CLp.php index e0634148de8..5d3691fd545 100644 --- a/src/CourseBundle/Entity/CLp.php +++ b/src/CourseBundle/Entity/CLp.php @@ -70,8 +70,9 @@ paginationClientEnabled: true, name: 'get_lp_collection_with_progress', provider: LpCollectionStateProvider::class, + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), - new Get(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('VIEW', object.resourceNode)"), new Post( controller: CreateCLpAction::class, openapi: new Operation( @@ -96,7 +97,7 @@ ]), ), ), - security: "is_granted('ROLE_TEACHER') or is_granted('ROLE_ADMIN')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", validationContext: ['groups' => ['lp:write']], deserialize: false, ), @@ -129,7 +130,7 @@ ]), ), ), - security: "is_granted('ROLE_TEACHER') or is_granted('ROLE_ADMIN')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", read: false, deserialize: false, name: 'lp_reorder' diff --git a/src/CourseBundle/Entity/CLpCategory.php b/src/CourseBundle/Entity/CLpCategory.php index ce9284812ab..23df651f2a4 100644 --- a/src/CourseBundle/Entity/CLpCategory.php +++ b/src/CourseBundle/Entity/CLpCategory.php @@ -34,8 +34,9 @@ openapi: new Operation( summary: 'List LP categories by course (resourceNode.parent) or sid', ), + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), - new Get(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('VIEW', object.resourceNode)"), ], normalizationContext: [ 'groups' => ['lp_category:read', 'resource_node:read', 'resource_link:read'], diff --git a/src/CourseBundle/Entity/CStudentPublication.php b/src/CourseBundle/Entity/CStudentPublication.php index 2af72f4e2ed..aff8b8f6013 100644 --- a/src/CourseBundle/Entity/CStudentPublication.php +++ b/src/CourseBundle/Entity/CStudentPublication.php @@ -65,7 +65,7 @@ ), ], ), - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", parameters: [ 'cid' => new QueryParameter( schema: ['type' => 'integer'], @@ -79,7 +79,7 @@ processor: CStudentPublicationDeleteProcessor::class ), new Post( - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", processor: CStudentPublicationPostStateProcessor::class ), new Post( diff --git a/src/CourseBundle/Entity/CStudentPublicationComment.php b/src/CourseBundle/Entity/CStudentPublicationComment.php index e87e72510d7..6a75405e5d3 100644 --- a/src/CourseBundle/Entity/CStudentPublicationComment.php +++ b/src/CourseBundle/Entity/CStudentPublicationComment.php @@ -28,12 +28,12 @@ operations: [ new GetCollection( uriTemplate: '/c_student_publication_comments', - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), new Post( uriTemplate: '/c_student_publication_comments/upload', controller: CreateStudentPublicationCommentAction::class, - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", deserialize: false, ), new Delete( diff --git a/src/CourseBundle/Entity/CStudentPublicationCorrection.php b/src/CourseBundle/Entity/CStudentPublicationCorrection.php index c668b95c582..aa412b6c6c4 100644 --- a/src/CourseBundle/Entity/CStudentPublicationCorrection.php +++ b/src/CourseBundle/Entity/CStudentPublicationCorrection.php @@ -24,7 +24,7 @@ new Post( uriTemplate: '/c_student_publication_corrections/upload', controller: CreateStudentPublicationCorrectionFileAction::class, - security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_TEACHER')", + security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')", deserialize: false, ), ], diff --git a/src/CourseBundle/Entity/CStudentPublicationRelDocument.php b/src/CourseBundle/Entity/CStudentPublicationRelDocument.php index bb517bb2943..91859a64c44 100644 --- a/src/CourseBundle/Entity/CStudentPublicationRelDocument.php +++ b/src/CourseBundle/Entity/CStudentPublicationRelDocument.php @@ -18,10 +18,10 @@ #[ApiResource( operations: [ - new Get(security: "is_granted('ROLE_USER')"), - new Delete(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_TEACHER')"), - new Post(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_TEACHER')"), - new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "object.getPublication() != null and is_granted('VIEW', object.getPublication().resourceNode)"), + new Delete(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')"), + new Post(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"), ], normalizationContext: ['groups' => ['student_publication_rel_document:read']], denormalizationContext: ['groups' => ['student_publication_rel_document:write']] diff --git a/src/CourseBundle/Entity/CStudentPublicationRelUser.php b/src/CourseBundle/Entity/CStudentPublicationRelUser.php index 6d2b765670b..c6d8d91515c 100644 --- a/src/CourseBundle/Entity/CStudentPublicationRelUser.php +++ b/src/CourseBundle/Entity/CStudentPublicationRelUser.php @@ -21,14 +21,14 @@ #[ORM\Entity] #[ApiResource( operations: [ - new Get(security: "is_granted('ROLE_USER')"), - new GetCollection(security: "is_granted('ROLE_USER')"), - new Post(security: "is_granted('ROLE_TEACHER') or is_granted('ROLE_SESSION_MANAGER')"), - new Delete(security: "is_granted('ROLE_TEACHER') or is_granted('ROLE_SESSION_MANAGER')"), + new Get(security: "object.getPublication() != null and is_granted('VIEW', object.getPublication().resourceNode)"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')"), + new Post(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')"), + new Delete(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')"), ], normalizationContext: ['groups' => ['student_publication_rel_user:read']], denormalizationContext: ['groups' => ['student_publication_rel_user:write']], - security: "is_granted('ROLE_USER')" + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')" )] #[ApiFilter(SearchFilter::class, properties: [ 'publication' => 'exact', diff --git a/src/CourseBundle/Entity/CTool.php b/src/CourseBundle/Entity/CTool.php index faf9664563f..cb1bd8af592 100644 --- a/src/CourseBundle/Entity/CTool.php +++ b/src/CourseBundle/Entity/CTool.php @@ -34,6 +34,7 @@ new GetCollection( output: CourseTool::class, provider: CToolStateProvider::class, + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", ), ], normalizationContext: ['groups' => ['ctool:read']], diff --git a/src/CourseBundle/Entity/CToolIntro.php b/src/CourseBundle/Entity/CToolIntro.php index f5a15f45fdb..f7bc94fa18a 100644 --- a/src/CourseBundle/Entity/CToolIntro.php +++ b/src/CourseBundle/Entity/CToolIntro.php @@ -46,7 +46,7 @@ ), ], ), - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", parameters: [ 'cid' => new QueryParameter( schema: ['type' => 'integer'], diff --git a/tests/CourseBundle/Api/CDocumentApiTest.php b/tests/CourseBundle/Api/CDocumentApiTest.php new file mode 100644 index 00000000000..70463e04cc8 --- /dev/null +++ b/tests/CourseBundle/Api/CDocumentApiTest.php @@ -0,0 +1,536 @@ +get(CourseRepository::class); + + /** @var CDocumentRepository $documentRepo */ + $documentRepo = self::getContainer()->get(CDocumentRepository::class); + + $course = $this->createCourse('Doc Sec Course '.$suffix); + $course->setVisibility(Course::REGISTERED); + + $teacher = $this->createUser('doc_sec_teacher_'.$suffix); + $student = $this->createUser('doc_sec_student_'.$suffix); + $attacker = $this->createUser('doc_sec_attacker_'.$suffix); + + $course->addUserAsTeacher($teacher); + $course->addUserAsStudent($student); + $courseRepo->update($course); + + $admin = $this->getAdmin(); + + $publishedDoc = (new CDocument()) + ->setFiletype('file') + ->setTitle('published-doc-'.$suffix) + ->setTemplate(false) + ->setReadonly(false) + ->setParent($course) + ->setCreator($admin) + ->addCourseLink($course, null, null, ResourceLink::VISIBILITY_PUBLISHED) + ; + $documentRepo->create($publishedDoc); + + $draftDoc = (new CDocument()) + ->setFiletype('file') + ->setTitle('draft-doc-'.$suffix) + ->setTemplate(false) + ->setReadonly(false) + ->setParent($course) + ->setCreator($admin) + ->addCourseLink($course, null, null, ResourceLink::VISIBILITY_DRAFT) + ; + $documentRepo->create($draftDoc); + + return [ + 'course' => $course, + 'teacher' => $teacher, + 'student' => $student, + 'attacker' => $attacker, + 'publishedDoc' => $publishedDoc, + 'draftDoc' => $draftDoc, + ]; + } + + /** + * Extracts the list of document titles from a Hydra collection payload. + * + * @param array $payload + * + * @return array + */ + private function extractTitles(array $payload): array + { + $members = $payload['hydra:member'] ?? []; + $titles = []; + foreach ($members as $member) { + if (isset($member['title'])) { + $titles[] = (string) $member['title']; + } + } + + return $titles; + } + + // ------------------------------------------------------------------------- + // Cross-course enumeration + // ------------------------------------------------------------------------- + + public function testListDocumentsOfPrivateCourseAsForeignUserIsForbidden(): void + { + $ctx = $this->bootstrapDocumentScenario('private_foreign'); + + $token = $this->getUserTokenFromUser($ctx['attacker']); + + $this->createClientWithCredentials($token)->request( + 'GET', + '/api/documents?cid='.$ctx['course']->getId().'&itemsPerPage=5000', + ); + + // After the fix the foreign user has no VIEW on the course → 403. + // Returning an empty 200 collection would also be acceptable defence, + // but we prefer the explicit denial (matches the suggested remediation). + $this->assertResponseStatusCodeSame(403); + } + + public function testListDocumentsWithoutAnyCidAsForeignUserDoesNotLeakOtherCourses(): void + { + $ctx = $this->bootstrapDocumentScenario('no_cid_foreign'); + + $token = $this->getUserTokenFromUser($ctx['attacker']); + + $response = $this->createClientWithCredentials($token)->request( + 'GET', + '/api/documents?itemsPerPage=5000', + ); + + // No cid context → API Platform rejects the request because `cid` is + // declared as a required QueryParameter on the GetCollection. The + // validator returns 422 Unprocessable Entity. + $this->assertResponseStatusCodeSame(422); + } + + // ------------------------------------------------------------------------- + // Draft visibility — non-teachers must never see DRAFT documents + // ------------------------------------------------------------------------- + + public function testListDocumentsAsEnrolledStudentExcludesDrafts(): void + { + $ctx = $this->bootstrapDocumentScenario('student_draft'); + + $token = $this->getUserTokenFromUser($ctx['student']); + + $response = $this->createClientWithCredentials($token)->request( + 'GET', + '/api/documents?cid='.$ctx['course']->getId().'&itemsPerPage=5000', + ); + + $this->assertResponseStatusCodeSame(200); + + $titles = $this->extractTitles($response->toArray()); + $this->assertContains('published-doc-student_draft', $titles, 'Published document must be visible to enrolled student.'); + $this->assertNotContains('draft-doc-student_draft', $titles, 'DRAFT document must be filtered out for students.'); + } + + public function testListDocumentsAsEnrolledTeacherIncludesDrafts(): void + { + $ctx = $this->bootstrapDocumentScenario('teacher_draft'); + + $token = $this->getUserTokenFromUser($ctx['teacher']); + + $response = $this->createClientWithCredentials($token)->request( + 'GET', + '/api/documents?cid='.$ctx['course']->getId().'&itemsPerPage=5000', + ); + + $this->assertResponseStatusCodeSame(200); + + $titles = $this->extractTitles($response->toArray()); + $this->assertContains('published-doc-teacher_draft', $titles); + $this->assertContains('draft-doc-teacher_draft', $titles, 'Teachers must keep access to DRAFT documents.'); + } + + public function testListDocumentsAsAdminIncludesDrafts(): void + { + $ctx = $this->bootstrapDocumentScenario('admin_draft'); + + // Default getUserToken() returns the admin/admin token. + $response = $this->createClientWithCredentials()->request( + 'GET', + '/api/documents?cid='.$ctx['course']->getId().'&itemsPerPage=5000', + ); + + $this->assertResponseStatusCodeSame(200); + + $titles = $this->extractTitles($response->toArray()); + $this->assertContains('published-doc-admin_draft', $titles); + $this->assertContains('draft-doc-admin_draft', $titles); + } + + // ------------------------------------------------------------------------- + // Unauthenticated access + // ------------------------------------------------------------------------- + + public function testListDocumentsRequiresAuthentication(): void + { + $ctx = $this->bootstrapDocumentScenario('auth_required'); + + $response = self::createClient()->request( + 'GET', + '/api/documents?cid='.$ctx['course']->getId(), + ); + + // The API firewall is JWT-gated. Without a token Symfony evaluates the + // operation-level `is_granted('ROLE_USER')` expression against an + // anonymous user and denies — yielding 401 (when an entry point sets + // the challenge) or 403 (default JWT firewall behaviour). Either is a + // valid "unauthenticated" outcome; what must never happen is a 200 + // with data. + $status = $response->getStatusCode(); + $this->assertContains( + $status, + [401, 403], + 'Expected 401 or 403 for an unauthenticated request, got '.$status + ); + } + + // ------------------------------------------------------------------------- + // Audit findings — separate from the original advisory. + // The helpers below piggy-back on bootstrapDocumentScenario(); each test + // documents a distinct cross-course / IDOR vector found while auditing. + // ------------------------------------------------------------------------- + + /** + * H1 — `GET /api/documents/{iid}/lp-usage` declares only `ROLE_USER` and + * the action does not check VIEW on the target document. A foreign user + * must not be able to enumerate the learning paths that reference a + * document of a private course they are not enrolled in. + */ + public function testLpUsageOfPrivateDocumentAsForeignUserIsForbidden(): void + { + $ctx = $this->bootstrapDocumentScenario('lp_usage_foreign'); + + $token = $this->getUserTokenFromUser($ctx['attacker']); + + $this->createClientWithCredentials($token)->request( + 'GET', + '/api/documents/'.$ctx['publishedDoc']->getIid().'/lp-usage', + ); + + // After the fix the endpoint must require VIEW on object.resourceNode. + $this->assertResponseStatusCodeSame(403); + } + + /** + * H2 — `GET /api/documents/{cid}/usage` returns storage quotas and + * breakdowns of any course as long as the caller is authenticated. + * A foreign user must not be able to read the operational metrics of a + * private course they are not enrolled in. + */ + public function testUsageOfPrivateCourseAsForeignUserIsForbidden(): void + { + $ctx = $this->bootstrapDocumentScenario('usage_foreign'); + + $token = $this->getUserTokenFromUser($ctx['attacker']); + + $this->createClientWithCredentials($token)->request( + 'GET', + '/api/documents/'.$ctx['course']->getId().'/usage', + ); + + // After the fix the controller must check CourseVoter::VIEW. + $this->assertResponseStatusCodeSame(403); + } + + /** + * H3 — `POST /api/documents` does not require `cid` and does not validate + * EDIT on every course referenced in `resourceLinkList`. A teacher of + * course A must not be able to create a document linked to course B. + */ + public function testCreateDocumentLinkedToForeignCourseIsForbidden(): void + { + $victimCtx = $this->bootstrapDocumentScenario('create_victim'); + $attackerCtx = $this->bootstrapDocumentScenario('create_attacker'); + + // The attacker is a teacher in their own course but NOT in victim's. + $attackerTeacher = $attackerCtx['teacher']; + $token = $this->getUserTokenFromUser($attackerTeacher); + + $this->createClientWithCredentials($token)->request( + 'POST', + '/api/documents?cid='.$attackerCtx['course']->getId(), + [ + 'json' => [ + 'title' => 'injected-from-foreign-course', + 'filetype' => 'folder', + 'parentResourceNodeId' => $victimCtx['course']->getResourceNode()->getId(), + 'resourceLinkList' => [[ + 'cid' => $victimCtx['course']->getId(), + 'visibility' => ResourceLink::VISIBILITY_PUBLISHED, + ]], + ], + ] + ); + + // After the fix any resourceLinkList entry pointing to a course where + // the user is not teacher/admin must be rejected. + $this->assertResponseStatusCodeSame(403); + + // Sanity: no rogue document landed in the victim course. + $em = $this->getEntityManager(); + $em->clear(); + $rogue = $em->getRepository(CDocument::class) + ->findOneBy(['title' => 'injected-from-foreign-course']) + ; + $this->assertNull($rogue, 'A foreign teacher must not be able to seed documents in another course.'); + } + + /** + * H4 — `POST /api/documents/{iid}/replace` is gated by global/contextual + * teacher roles instead of `is_granted('EDIT', object.resourceNode)`. A + * teacher of course A must not be able to replace the binary of a + * document that belongs to course B. + */ + public function testReplaceDocumentOfForeignCourseIsForbidden(): void + { + $victimCtx = $this->bootstrapDocumentScenario('replace_victim'); + $attackerCtx = $this->bootstrapDocumentScenario('replace_attacker'); + + $attackerTeacher = $attackerCtx['teacher']; + $token = $this->getUserTokenFromUser($attackerTeacher); + + $file = $this->getUploadedFile(); + + $this->createClientWithCredentials($token)->request( + 'POST', + '/api/documents/'.$victimCtx['publishedDoc']->getIid().'/replace?cid='.$attackerCtx['course']->getId(), + [ + 'headers' => ['Content-Type' => 'multipart/form-data'], + 'extra' => ['files' => ['file' => $file]], + ] + ); + + // After the fix the operation must require EDIT on object.resourceNode. + $this->assertResponseStatusCodeSame(403); + } + + // ------------------------------------------------------------------------- + // Phase 3 — Regression tests for contextual-role migration (issue #8486). + // + // The following endpoints used to require only `ROLE_USER`, letting any + // authenticated user invoke them. They now require contextual roles + // (ROLE_CURRENT_COURSE_STUDENT / ROLE_CURRENT_COURSE_SESSION_STUDENT) + // populated by CourseContextRoleListener once CidReqListener has resolved + // the `cid` query parameter. + // ------------------------------------------------------------------------- + + public function testDownloadSelectedAsForeignUserIsForbidden(): void + { + $ctx = $this->bootstrapDocumentScenario('dl_sel_foreign'); + + $token = $this->getUserTokenFromUser($ctx['attacker']); + + $this->createClientWithCredentials($token)->request( + 'POST', + '/api/documents/download-selected?cid='.$ctx['course']->getId(), + [ + 'headers' => [ + 'Accept' => 'application/zip', + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'ids' => [$ctx['publishedDoc']->getIid()], + 'compressed' => true, + ], JSON_THROW_ON_ERROR), + ] + ); + + $this->assertResponseStatusCodeSame(403); + } + + public function testDownloadSelectedWithoutCidIsForbidden(): void + { + $ctx = $this->bootstrapDocumentScenario('dl_sel_no_cid'); + + $token = $this->getUserTokenFromUser($ctx['student']); + + $this->createClientWithCredentials($token)->request( + 'POST', + '/api/documents/download-selected', + [ + 'headers' => [ + 'Accept' => 'application/zip', + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'ids' => [$ctx['publishedDoc']->getIid()], + 'compressed' => true, + ], JSON_THROW_ON_ERROR), + ] + ); + + // Without cid the listener cannot publish contextual roles → security + // expression fails even for an otherwise-enrolled student. + $this->assertResponseStatusCodeSame(403); + } + + public function testDownloadSelectedAsEnrolledStudentIsAllowed(): void + { + $ctx = $this->bootstrapDocumentScenario('dl_sel_student'); + + $token = $this->getUserTokenFromUser($ctx['student']); + + $response = $this->createClientWithCredentials($token)->request( + 'POST', + '/api/documents/download-selected?cid='.$ctx['course']->getId(), + [ + 'headers' => [ + 'Accept' => 'application/zip', + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'ids' => [$ctx['publishedDoc']->getIid()], + 'compressed' => true, + ], JSON_THROW_ON_ERROR), + ] + ); + + // Security must pass for the enrolled student. The controller may + // still fail later if the physical file is missing in the test + // environment, but the security gate must not reject (403). + $this->assertNotSame( + 403, + $response->getStatusCode(), + 'Enrolled student with valid cid must pass the security expression.' + ); + } + + public function testDownloadAllAsForeignUserIsForbidden(): void + { + $ctx = $this->bootstrapDocumentScenario('dl_all_foreign'); + + $token = $this->getUserTokenFromUser($ctx['attacker']); + + $this->createClientWithCredentials($token)->request( + 'POST', + '/api/documents/download-all?cid='.$ctx['course']->getId(), + [ + 'headers' => [ + 'Accept' => 'application/zip', + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'rootNodeId' => $ctx['course']->getResourceNode()->getId(), + ], JSON_THROW_ON_ERROR), + ] + ); + + $this->assertResponseStatusCodeSame(403); + } + + public function testDownloadAllWithoutCidIsForbidden(): void + { + $ctx = $this->bootstrapDocumentScenario('dl_all_no_cid'); + + $token = $this->getUserTokenFromUser($ctx['student']); + + $this->createClientWithCredentials($token)->request( + 'POST', + '/api/documents/download-all', + [ + 'headers' => [ + 'Accept' => 'application/zip', + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'rootNodeId' => $ctx['course']->getResourceNode()->getId(), + ], JSON_THROW_ON_ERROR), + ] + ); + + $this->assertResponseStatusCodeSame(403); + } + + public function testDownloadAllAsEnrolledStudentIsAllowed(): void + { + $ctx = $this->bootstrapDocumentScenario('dl_all_student'); + + $token = $this->getUserTokenFromUser($ctx['student']); + + $response = $this->createClientWithCredentials($token)->request( + 'POST', + '/api/documents/download-all?cid='.$ctx['course']->getId(), + [ + 'headers' => [ + 'Accept' => 'application/zip', + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'rootNodeId' => $ctx['course']->getResourceNode()->getId(), + ], JSON_THROW_ON_ERROR), + ] + ); + + $this->assertNotSame( + 403, + $response->getStatusCode(), + 'Enrolled student with valid cid must pass the security expression.' + ); + } +}