From fe22585a411cb92f1d3e3a4b2ed8ac4770dc85b8 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 25 May 2026 17:36:05 -0500 Subject: [PATCH 01/38] Introduce `CourseContextRoleListener` to dynamically assign contextual course roles --- .../CourseContextRoleListener.php | 111 ++++++++++++++++ src/CoreBundle/Resources/config/listeners.yml | 7 + .../Security/CourseAccessResolver.php | 120 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 src/CoreBundle/EventListener/CourseContextRoleListener.php create mode 100644 src/CoreBundle/Security/CourseAccessResolver.php 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/Resources/config/listeners.yml b/src/CoreBundle/Resources/config/listeners.yml index fe32cb20c50..a0391441e00 100644 --- a/src/CoreBundle/Resources/config/listeners.yml +++ b/src/CoreBundle/Resources/config/listeners.yml @@ -15,6 +15,13 @@ services: - {name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 6} - {name: kernel.event_listener, event: kernel.controller, method: onKernelController} + # Publishes ROLE_CURRENT_COURSE_* on the token after CidReqListener (priority 6) + # has resolved the cid/sid/gid context. Must run with a lower priority so the + # course, session and group are already available in the request session. + Chamilo\CoreBundle\EventListener\CourseContextRoleListener: + tags: + - {name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 5} + # Sets the user access in a course listener Chamilo\CoreBundle\EventListener\CourseAccessListener: tags: 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, + ); + } +} From 3fb900c0be03a85da24e843c92adc93ac93d7504 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 25 May 2026 17:53:59 -0500 Subject: [PATCH 02/38] Refactor `Voter` classes to remove dynamic role assignments from users and streamline access checks --- .../Authorization/Voter/CourseVoter.php | 123 ++++-------------- .../Authorization/Voter/GroupVoter.php | 10 -- .../Authorization/Voter/SessionVoter.php | 27 +--- 3 files changed, 30 insertions(+), 130 deletions(-) diff --git a/src/CoreBundle/Security/Authorization/Voter/CourseVoter.php b/src/CoreBundle/Security/Authorization/Voter/CourseVoter.php index f883801ea19..7b54623e324 100644 --- a/src/CoreBundle/Security/Authorization/Voter/CourseVoter.php +++ b/src/CoreBundle/Security/Authorization/Voter/CourseVoter.php @@ -64,7 +64,7 @@ protected function supports(string $attribute, $subject): bool protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool { - $tokenUser = $token->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; } From d1913ee0d8e7c62f816a9b7f597b5c2f30b48e10 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 25 May 2026 18:31:30 -0500 Subject: [PATCH 03/38] Security: Restrict document access to course-specific roles (`ROLE_CURRENT_COURSE_STUDENT` and `ROLE_CURRENT_COURSE_SESSION_STUDENT`) --- src/CourseBundle/Entity/CDocument.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php index f966261d176..093beda9424 100644 --- a/src/CourseBundle/Entity/CDocument.php +++ b/src/CourseBundle/Entity/CDocument.php @@ -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,7 +245,7 @@ ]), ), ), - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", deserialize: false ), new GetCollection( @@ -274,7 +274,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( From 35dda3fdb253d4230208b9be61fffed3c410b8ff Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 25 May 2026 19:09:39 -0500 Subject: [PATCH 04/38] Security: Add regression tests for CDocument API to prevent unauthorized document access --- tests/CourseBundle/Api/CDocumentApiTest.php | 536 ++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 tests/CourseBundle/Api/CDocumentApiTest.php 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.' + ); + } +} From b7377565cef8741507fd285743fa119714b006bc Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Mon, 25 May 2026 20:04:03 -0500 Subject: [PATCH 05/38] Security: Enforce course-teacher validation for `resourceLinkList` in `CreateDocumentFileAction` to prevent IDOR vulnerabilities --- .../Api/CreateDocumentFileAction.php | 71 +++++++++++++++++++ src/CourseBundle/Entity/CDocument.php | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) 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/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php index 093beda9424..3a998e88253 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 ), From 2c88ac5b4895013c801f6a1fc366a425652031c4 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Tue, 26 May 2026 03:36:51 -0500 Subject: [PATCH 06/38] Introduce `OptionalCourseLinkFilter` to allow optional `cid` handling in API requests --- src/CoreBundle/Filter/CidFilter.php | 9 +++++++-- .../Filter/OptionalCourseLinkFilter.php | 15 +++++++++++++++ src/CourseBundle/Entity/CCalendarEvent.php | 4 ++-- src/CourseBundle/Entity/CDocument.php | 14 -------------- 4 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 src/CoreBundle/Filter/OptionalCourseLinkFilter.php 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 @@ + '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 3a998e88253..d50099d3abb 100644 --- a/src/CourseBundle/Entity/CDocument.php +++ b/src/CourseBundle/Entity/CDocument.php @@ -249,22 +249,8 @@ 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', From fc2d92a5932d1e197c69f0059168a11ac00220bb Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Tue, 26 May 2026 03:57:59 -0500 Subject: [PATCH 07/38] Replace manual `cid`, `sid`, and `gid` handling with `CidReqHelper` for cleaner context management in `DocumentCollectionStateProvider` --- .../State/DocumentCollectionStateProvider.php | 61 +++++++++---------- 1 file changed, 28 insertions(+), 33 deletions(-) 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'; } From 828158e0e8605e6ff239b7a237f097fa0e491e2a Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Tue, 26 May 2026 05:18:54 -0500 Subject: [PATCH 08/38] Enforce contextual course security roles and improve attendance API handling with parameterized context management. --- assets/vue/services/attendanceService.js | 15 ++++++++++++--- src/CourseBundle/Entity/CAttendance.php | 14 ++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) 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/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')] From 0035108fb4ae0dfeaa7f44357c3124437f14d8e7 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Tue, 26 May 2026 08:19:11 -0500 Subject: [PATCH 09/38] Security: Enforce contextual validation of `resourceLinkList` in `CreateCBlogAction` to prevent bypassing course, session, or group access gates. --- .../Controller/Api/CreateCBlogAction.php | 50 +++++++++++++++++++ src/CourseBundle/Entity/CBlog.php | 6 ++- 2 files changed, 54 insertions(+), 2 deletions(-) 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/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 ), From bb1c4c6a1e3743d2b2b56495e387d916e59e81d4 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:31:41 -0500 Subject: [PATCH 10/38] Security: Glossary: Enforce contextual course roles on API operations Remove the platform-wide ROLE_TEACHER fallback from the create/import/export operations (it allowed any teacher of the platform to operate on any course), and add a contextual read role to the GetCollection that previously relied on the default ROLE_USER firewall check. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CGlossary.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 ), From a5f722ff686ceda7546c5e13171f0dee2542860b Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:33:27 -0500 Subject: [PATCH 11/38] Security: Link: Enforce contextual course roles on API operations Drop the platform-wide ROLE_TEACHER fallback from the create/export operations and add a contextual read role to the GetCollection that previously relied on the default ROLE_USER firewall check. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CLink.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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: [ From 43bc378500662706ae9ca7f33706cad2ec559924 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:35:45 -0500 Subject: [PATCH 12/38] Security: LinkCategory: Enforce contextual course roles on API operations Drop the platform-wide ROLE_TEACHER fallback from the create operation and add a contextual read role to the GetCollection that previously relied on the default ROLE_USER firewall check. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CLinkCategory.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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: [ From ff882ffde2fc544e5cd529f4d95f2568c9a4606e Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:38:29 -0500 Subject: [PATCH 13/38] Security: LearningPathCategory: Enforce contextual course roles on API operations Replace the lax ROLE_USER check on the item Get with an object-level VIEW on the resource node, and add a contextual read role to the GetCollection (CidFilter already scopes results to the requested course). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CLpCategory.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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'], From e9cd03b7a39173ec838f1cd576e2d677abf6d20a Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:40:09 -0500 Subject: [PATCH 14/38] Security: LearningPath: Enforce contextual course roles on API operations Replace the lax ROLE_USER on the item Get with an object-level VIEW, add a contextual read role to the GetCollection, and swap the platform-wide ROLE_TEACHER/ROLE_ADMIN check on create and reorder for contextual teacher roles (admins keep access via the role hierarchy). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CLp.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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' From bd00fbc0614df9ba9656b8e12744438d9c876e1b Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:42:13 -0500 Subject: [PATCH 15/38] Security: StudentPublication: Enforce contextual course roles on API operations Add a contextual read role to the GetCollection (replacing the lax ROLE_USER) and drop the platform-wide ROLE_TEACHER fallback from the create operation. The /upload operation (ROLE_STUDENT / ROLE_STUDENT_BOSS) is intentionally left unchanged: switching it to contextual roles would change who can submit assignments and affect student-boss supervisors, which needs a separate decision. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CStudentPublication.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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( From 77e919caba69084443c6c28b2484c616d686e837 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:44:11 -0500 Subject: [PATCH 16/38] Security: StudentPublicationComment: Enforce contextual course roles Replace the lax ROLE_USER on the comment list and upload operations with contextual course/session read roles. The Delete operation keeps its owner-only check (object.getUser() == user), which is already stricter. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CStudentPublicationComment.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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( From 0c96faf1dd903eab21a4ef89276d2c1fca7782ca Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:45:16 -0500 Subject: [PATCH 17/38] Security: ToolIntro: Enforce contextual read role on GetCollection Replace the lax ROLE_USER on the tool-intro list with contextual course/session read roles (CidFilter already scopes results to the requested course). Item operations already use object-level checks. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CToolIntro.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'], From 2a57170faabda6961c9c8aaf3abe5f19ad4a74af Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:46:12 -0500 Subject: [PATCH 18/38] Security: BlogPost: Enforce contextual read role on GetCollection Replace the lax ROLE_USER on the blog-post list with contextual course/session read roles. Item operations already gate through the parent blog's resource node. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CBlogPost.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From c7a2cc5a898843c1af74ed9ac6287bf61c2fdb01 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:46:53 -0500 Subject: [PATCH 19/38] Security: BlogComment: Enforce contextual read role on GetCollection Replace the lax ROLE_USER on the blog-comment list with contextual course/session read roles. Item operations already gate through the parent blog/post resource node. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CBlogComment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From e115396f532570a80e0328f5428cbfa55be0d578 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:48:12 -0500 Subject: [PATCH 20/38] Security: BlogTask: Enforce contextual/object-level roles on API operations Align the blog-task operations with the rest of the blog module: gate item reads/writes through the parent blog's resource node (plus author ownership for patch/delete), add a contextual read role to the GetCollection, and require EDIT on the blog to create a task. Removes the lax ROLE_USER and the platform-wide ROLE_TEACHER/ROLE_ADMIN fallbacks (admins keep access via the role hierarchy). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CBlogTask.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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']] From 5d670c0f3162029be224a82d879f57fa5653ee24 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:49:40 -0500 Subject: [PATCH 21/38] Security: BlogRelUser: Enforce contextual read role on GetCollection Replace the lax ROLE_USER on the blog-membership list with contextual course/session read roles. Write operations already gate through the parent blog's resource node. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CBlogRelUser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)"), ], From d2c7335015a6ff7282cdc534d94a828bbb08e363 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:51:06 -0500 Subject: [PATCH 22/38] Security: BlogAttachment: Enforce contextual/object-level roles Gate item reads and creates through the parent blog's resource node, add a contextual read role to the GetCollection, and require course membership for the upload endpoint (was IS_AUTHENTICATED_REMEMBERED). Removes the lax ROLE_USER. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CBlogAttachment.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 ), From 78870cc6263606d8015d044f3ad0bbae2f8fda69 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:52:57 -0500 Subject: [PATCH 23/38] Security: BlogRating: Enforce contextual read role at resource level Replace the resource-wide ROLE_USER with contextual course/session roles so ratings can only be listed and submitted by members of the current course. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CBlogRating.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From 65ddd49696d45c2ace54412fab2adba6904255b8 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:54:01 -0500 Subject: [PATCH 24/38] Security: BlogTaskRelUser: Enforce contextual/object-level roles Gate item reads through the parent blog's resource node, add a contextual read role to the GetCollection, and replace the platform-wide ROLE_TEACHER/ROLE_ADMIN fallbacks on create/patch with contextual teacher roles (the patch keeps its owner check; admins keep access via the role hierarchy). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CBlogTaskRelUser.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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']], From 23c2ec16b64ae887302eb5612e46386805628c49 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:54:30 -0500 Subject: [PATCH 25/38] Security: StudentPublicationCorrection: Enforce contextual teacher roles Replace the platform-wide ROLE_TEACHER fallback on the correction upload with contextual course/session teacher roles. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CStudentPublicationCorrection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, ), ], From f4876c03ac6af09d7b4127bb3194ef469cd1bd55 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:55:18 -0500 Subject: [PATCH 26/38] Security: StudentPublicationRelDocument: Enforce contextual/object-level roles Gate the item Get through the parent publication's resource node, add a contextual read role to the GetCollection, and drop the platform-wide ROLE_TEACHER fallback from create/delete in favour of contextual teacher roles. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Entity/CStudentPublicationRelDocument.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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']] From 5ac887336292488f355e20a2823930b6a42536a9 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:56:45 -0500 Subject: [PATCH 27/38] Security: StudentPublicationRelUser: Enforce contextual/object-level roles Gate the item Get through the parent publication's resource node, add a contextual read role to the GetCollection and resource-level fallback, and replace the platform-wide ROLE_TEACHER/ROLE_SESSION_MANAGER checks on create/delete with contextual teacher roles. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CStudentPublicationRelUser.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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', From d802c391ffcee8deaa1082f2022a2fd063e04623 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:57:27 -0500 Subject: [PATCH 28/38] Security: Tool: Enforce contextual read role on GetCollection Add a contextual course/session read role to the course-tools list, which previously relied on the default ROLE_USER firewall check (CidFilter already scopes results to the requested course). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CTool.php | 1 + 1 file changed, 1 insertion(+) 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']], From 09c93501fa2eee9c685c09cd9c30bec31cebf773 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:58:19 -0500 Subject: [PATCH 29/38] Security: Group: Enforce contextual read role on GetCollection Add a contextual course/session read role to the groups list, which previously relied on the default ROLE_USER firewall check. The item Get already uses an object-level VIEW on the resource node. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CGroup.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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)"), ], From 3160bc15361aaaec41b8132193b21fc9ac2ce7f6 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 00:58:52 -0500 Subject: [PATCH 30/38] Security: DropboxFile: Enforce contextual roles on upload Replace the very lax IS_AUTHENTICATED_REMEMBERED check on the dropbox upload with contextual course/session roles, so only members of the current course can share files through the dropbox. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CDropboxFile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 61fcfafbb28eddabcd527ca4707fcebaa2876630 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 17:02:20 -0500 Subject: [PATCH 31/38] Security: AttendanceCalendar: Enforce contextual/object-level roles Replace the resource-wide platform ROLE_TEACHER check with explicit operations gated through the parent attendance's resource node: object-level VIEW for the item Get, object-level EDIT for create/update/delete, and a contextual teacher role for the collection. Removes the cross-course IDOR allowed by the plain ROLE_TEACHER role. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CAttendanceCalendar.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/CourseBundle/Entity/CAttendanceCalendar.php b/src/CourseBundle/Entity/CAttendanceCalendar.php index 5f3fd0ffab2..9fc14aee92c 100644 --- a/src/CourseBundle/Entity/CAttendanceCalendar.php +++ b/src/CourseBundle/Entity/CAttendanceCalendar.php @@ -7,16 +7,29 @@ namespace Chamilo\CourseBundle\Entity; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use DateTime; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( + operations: [ + new Get(security: "object.getAttendance() != null and is_granted('VIEW', object.getAttendance().resourceNode)"), + new GetCollection(security: "is_granted('ROLE_CURRENT_COURSE_TEACHER') or is_granted('ROLE_CURRENT_COURSE_SESSION_TEACHER')"), + new Post(securityPostDenormalize: "object.getAttendance() != null and is_granted('EDIT', object.getAttendance().resourceNode)"), + new Put(security: "object.getAttendance() != null and is_granted('EDIT', object.getAttendance().resourceNode)"), + new Patch(security: "object.getAttendance() != null and is_granted('EDIT', object.getAttendance().resourceNode)"), + new Delete(security: "object.getAttendance() != null and is_granted('EDIT', object.getAttendance().resourceNode)"), + ], normalizationContext: ['groups' => ['attendance_calendar:read']], denormalizationContext: ['groups' => ['attendance_calendar:write']], paginationEnabled: false, - security: "is_granted('ROLE_TEACHER')" )] #[ORM\Table(name: 'c_attendance_calendar')] #[ORM\Index(columns: ['done_attendance'], name: 'done_attendance')] From 4c5f54f17069c9a6d89d6af50210e5ccb69a44a8 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 17:10:00 -0500 Subject: [PATCH 32/38] Security: Document: Drop platform ROLE_TEACHER fallback from create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the platform-wide ROLE_TEACHER fallback from the document create operation, leaving only the contextual course/session teacher roles — aligning it with the rest of the migrated CourseBundle resources. The controller already validates that resourceLinkList entries match the request's course context. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CDocument.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php index d50099d3abb..ca28276868e 100644 --- a/src/CourseBundle/Entity/CDocument.php +++ b/src/CourseBundle/Entity/CDocument.php @@ -161,7 +161,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', 'document:write']], deserialize: false ), From 3e73c9db23d298c212ce98b8918dfa743a6a54cf Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sat, 30 May 2026 17:12:59 -0500 Subject: [PATCH 33/38] Security: Document: Enforce contextual read role on usage endpoint Replace the lax ROLE_USER on GET /documents/{cid}/usage with contextual course/session read roles. The cid path parameter is read by CidReqListener (via request attributes), so the contextual roles are published for that course. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CourseBundle/Entity/CDocument.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CourseBundle/Entity/CDocument.php b/src/CourseBundle/Entity/CDocument.php index ca28276868e..514ef233f9f 100644 --- a/src/CourseBundle/Entity/CDocument.php +++ b/src/CourseBundle/Entity/CDocument.php @@ -269,7 +269,7 @@ openapiContext: [ 'summary' => 'Get usage/quota information for documents.', ], - security: "is_granted('ROLE_USER')", + security: "is_granted('ROLE_CURRENT_COURSE_STUDENT') or is_granted('ROLE_CURRENT_COURSE_SESSION_STUDENT')", read: false, name: 'api_documents_usage' ), From 26c7da99e7766bea42fc289ac8dca3673b017310 Mon Sep 17 00:00:00 2001 From: Angel Fernando Quiroz Campos <1697880+AngelFQC@users.noreply.github.com> Date: Sun, 31 May 2026 01:47:58 -0500 Subject: [PATCH 34/38] Ensure resource link list are derived from the session-resolved course context, ignoring body-provided cid/sid/gid values to prevent IDOR vulnerabilities. Removes redundant manual checks for context mismatches. --- assets/vue/views/blog/BlogAdmin.vue | 8 +- .../vue/views/documents/DocumentsUpload.vue | 46 ++++------ .../Controller/Api/BaseResourceFileAction.php | 92 ++++++++++++++++++- .../Controller/Api/CreateCBlogAction.php | 59 ++---------- .../Api/CreateDocumentFileAction.php | 86 +++-------------- 5 files changed, 133 insertions(+), 158 deletions(-) diff --git a/assets/vue/views/blog/BlogAdmin.vue b/assets/vue/views/blog/BlogAdmin.vue index 82b5ef5b118..ec5c334f774 100644 --- a/assets/vue/views/blog/BlogAdmin.vue +++ b/assets/vue/views/blog/BlogAdmin.vue @@ -172,11 +172,9 @@ import ProjectCreateDialog from "../../components/blog/ProjectCreateDialog.vue" import service from "../../services/blogs" import { useSecurityStore } from "../../store/securityStore" import { RESOURCE_LINK_DRAFT } from "../../constants/entity/resourcelink" -import { getCourseContext } from "../../utils/courseContext" import SectionHeader from "../../components/layout/SectionHeader.vue" import BaseCheckbox from "../../components/basecomponents/BaseCheckbox.vue" -const { cid, sid } = getCourseContext() const { t } = useI18n() const route = useRoute() @@ -184,9 +182,11 @@ const route = useRoute() const securityStore = useSecurityStore() const isAdminOrTeacher = computed(() => securityStore.isAdmin || securityStore.isTeacher) -// Parent node and default link list (created as draft) +// Parent node and default link list (created as draft). The course context +// (cid/sid/gid) is derived server-side from the gated session course, so the +// link list only carries the visibility. const parentResourceNodeId = ref(Number(route.params.node)) -const resourceLinkList = ref([{ sid, cid, visibility: RESOURCE_LINK_DRAFT }]) +const resourceLinkList = ref([{ visibility: RESOURCE_LINK_DRAFT }]) // UI state const q = ref("") diff --git a/assets/vue/views/documents/DocumentsUpload.vue b/assets/vue/views/documents/DocumentsUpload.vue index ae666ecdf90..d57475732b2 100644 --- a/assets/vue/views/documents/DocumentsUpload.vue +++ b/assets/vue/views/documents/DocumentsUpload.vue @@ -221,7 +221,7 @@