diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 6e1938fb..1e0d4733 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -26,6 +26,7 @@ from src.services.courses.courses import ( delete_course, update_course_thumbnail, search_courses, + get_course_user_rights, ) from src.services.courses.updates import ( create_update, @@ -358,12 +359,94 @@ async def api_remove_bulk_course_contributors( ): """ Remove multiple contributors from a course by their usernames - Only administrators can perform this action """ return await remove_bulk_course_contributors( - request, - course_uuid, - usernames, - current_user, - db_session + request, course_uuid, usernames, current_user, db_session ) + + +@router.get("/{course_uuid}/rights") +async def api_get_course_user_rights( + request: Request, + course_uuid: str, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> dict: + """ + Get detailed user rights for a specific course. + + This endpoint returns comprehensive rights information that can be used + by the UI to enable/disable features based on user permissions. + + + + **Response Structure:** + ```json + { + "course_uuid": "course_123", + "user_id": 456, + "is_anonymous": false, + "permissions": { + "read": true, + "create": false, + "update": true, + "delete": false, + "create_content": true, + "update_content": true, + "delete_content": true, + "manage_contributors": true, + "manage_access": true, + "grade_assignments": true, + "mark_activities_done": true, + "create_certifications": true + }, + "ownership": { + "is_owner": true, + "is_creator": true, + "is_maintainer": false, + "is_contributor": false, + "authorship_status": "ACTIVE" + }, + "roles": { + "is_admin": false, + "is_maintainer_role": false, + "is_instructor": true, + "is_user": true + } + } + ``` + + **Permissions Explained:** + - `read`: Can read the course content + - `create`: Can create new courses (instructor role or higher) + - `update`: Can update course settings (title, description, etc.) + - `delete`: Can delete the course + - `create_content`: Can create activities, assignments, chapters, etc. + - `update_content`: Can update course content + - `delete_content`: Can delete course content + - `manage_contributors`: Can add/remove contributors + - `manage_access`: Can change course access settings (public, open_to_contributors) + - `grade_assignments`: Can grade student assignments + - `mark_activities_done`: Can mark activities as done for other users + - `create_certifications`: Can create course certifications + + **Ownership Information:** + - `is_owner`: Is course owner (CREATOR, MAINTAINER, or CONTRIBUTOR) + - `is_creator`: Is course creator + - `is_maintainer`: Is course maintainer + - `is_contributor`: Is course contributor + - `authorship_status`: Current authorship status (ACTIVE, PENDING, INACTIVE) + + **Role Information:** + - `is_admin`: Has admin role (role 1) + - `is_maintainer_role`: Has maintainer role (role 2) + - `is_instructor`: Has instructor role (role 3) + - `is_user`: Has basic user role (role 4) + + **Security Notes:** + - Returns rights based on course ownership and user roles + - Safe to expose to UI as it only returns permission information + - Anonymous users can only read public courses + - All permissions are calculated based on current user context + """ + return await get_course_user_rights(request, course_uuid, current_user, db_session) diff --git a/apps/api/src/security/courses_security.py b/apps/api/src/security/courses_security.py new file mode 100644 index 00000000..6bca2b92 --- /dev/null +++ b/apps/api/src/security/courses_security.py @@ -0,0 +1,410 @@ +""" +SECURITY DOCUMENTATION FOR COURSES RBAC SYSTEM + +This module provides unified RBAC (Role-Based Access Control) checks for all courses-related operations. + +SECURITY MEASURES IMPLEMENTED: + +1. COURSE OWNERSHIP REQUIREMENTS: + - All non-read operations (create, update, delete) require course ownership + - Course ownership is determined by ResourceAuthor table with ACTIVE status + - Valid ownership roles: CREATOR, MAINTAINER, CONTRIBUTOR + - Admin/maintainer roles are also accepted for course operations + +2. COURSE CREATION VS COURSE CONTENT CREATION: + - COURSE CREATION: Allow if user has instructor role (3) or higher + - COURSE CONTENT CREATION (activities, assignments, chapters, etc.): Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role + - This distinction allows instructors to create courses but prevents them from creating content in courses they don't own + +3. STRICT ACCESS CONTROLS: + - Activities: Require course ownership for all non-read operations + - Assignments: Require course ownership for all non-read operations + - Chapters: Require course ownership for all non-read operations + - Certifications: Require course ownership for all non-read operations + - Collections: Use organization-level permissions + +4. GRADING AND SUBMISSION SECURITY: + - Only course owners or instructors can grade assignments + - Users can only submit their own work + - Users cannot update grades unless they are instructors + - Users can only update their own submissions + +5. CERTIFICATE SECURITY: + - Certificates can only be created by course owners or instructors + - System-generated certificates (from course completion) are properly secured + - Certificate creation requires proper RBAC checks + +6. ACTIVITY MARKING SECURITY: + - Only course owners or instructors can mark activities as done for other users + - Users can only mark their own activities as done + +7. COLLECTION SECURITY: + - Users can only add courses to collections if they have read access to those courses + - Collection operations require appropriate organization-level permissions + +8. ANONYMOUS USER HANDLING: + - Anonymous users can only read public courses + - All non-read operations require authentication + +9. ERROR HANDLING: + - Clear error messages for security violations + - Proper HTTP status codes (401, 403, 404) + - Comprehensive logging of security events + +10. COURSE ACCESS MANAGEMENT SECURITY: + - Sensitive fields (public, open_to_contributors) require additional validation + - Only course owners (CREATOR, MAINTAINER) or admins can change access settings + - Course creation requires proper organization-level permissions + - Course updates require course ownership or admin role + +11. CONTRIBUTOR MANAGEMENT SECURITY: + - Only course owners (CREATOR, MAINTAINER) or admins can add/remove contributors + - Only course owners (CREATOR, MAINTAINER) or admins can update contributor roles + - Cannot modify the role of the course creator + - Contributor applications are created with PENDING status + - Only course owners or admins can approve contributor applications + +SECURITY BEST PRACTICES: +- Always check course ownership before allowing modifications +- Validate user permissions at multiple levels +- Use proper RBAC checks for all operations +- Implement principle of least privilege +- Provide clear error messages for security violations +- Log security events for audit purposes +- Additional validation for sensitive access control fields +- Strict ownership requirements for contributor management +- Distinguish between course creation and course content creation permissions + +CRITICAL SECURITY FIXES: +- Fixed: Users could create certifications for courses they don't own +- Fixed: Users could grade assignments without proper permissions +- Fixed: Users could mark activities as done for other users without permissions +- Fixed: Collections could be created with courses the user doesn't have access to +- Fixed: Assignment submissions could be modified by unauthorized users +- Fixed: Users could change course access settings (public, open_to_contributors) without proper permissions +- Fixed: Users could add/remove contributors from courses they don't own +- Fixed: Users could update contributor roles without course ownership +- Fixed: Course creation used hardcoded RBAC check +- Fixed: Contributor management used permissive RBAC checks instead of strict ownership requirements +- Fixed: Instructors could create content in courses they don't own (now they can only create courses) +""" + +from typing import Literal +from fastapi import HTTPException, Request, status +from sqlmodel import Session, select +from src.db.users import AnonymousUser, PublicUser +from src.db.courses.courses import Course +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_element_is_public, + authorization_verify_if_user_is_anon, + authorization_verify_based_on_org_admin_status, +) + + +async def courses_rbac_check( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, + require_course_ownership: bool = False, +) -> bool: + """ + Unified RBAC check for courses-related operations. + + SECURITY NOTES: + - READ operations: Allow if user has read access to the course (public courses or user has permissions) + - COURSE CREATION: Allow if user has instructor role (3) or higher + - COURSE CONTENT CREATION (activities, assignments, chapters, etc.): Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role + - UPDATE/DELETE operations: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role + - Course ownership is determined by ResourceAuthor table with ACTIVE status + - Admin/maintainer roles are checked via authorization_verify_based_on_org_admin_status + + Args: + request: FastAPI request object + course_uuid: UUID of the course (or "course_x" for course creation) + current_user: Current user (PublicUser or AnonymousUser) + action: Action to perform (create, read, update, delete) + db_session: Database session + require_course_ownership: If True, requires course ownership for non-read actions + + Returns: + bool: True if authorized, raises HTTPException otherwise + + Raises: + HTTPException: 403 Forbidden if user lacks required permissions + HTTPException: 401 Unauthorized if user is anonymous for non-read actions + """ + + if action == "read": + if current_user.id == 0: # Anonymous user + return await authorization_verify_if_element_is_public( + request, course_uuid, action, db_session + ) + else: + return await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, action, course_uuid, db_session + ) + else: + # For non-read actions, proceed with strict RBAC checks + await authorization_verify_if_user_is_anon(current_user.id) + + # SECURITY: Special handling for course creation vs course content creation + if action == "create" and course_uuid == "course_x": + # This is course creation - allow instructors (role 3) or higher + # Check if user has instructor role or higher + from src.security.rbac.rbac import authorization_verify_based_on_roles + + has_create_permission = await authorization_verify_based_on_roles( + request, current_user.id, "create", "course_x", db_session + ) + + if has_create_permission: + return True + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You must have instructor role or higher to create courses", + ) + + # SECURITY: For course content creation and other operations, require course ownership + # This prevents users without course ownership from creating/modifying course content + if require_course_ownership or action in ["create", "update", "delete"]: + # Check if user is course owner (CREATOR, MAINTAINER, or CONTRIBUTOR) + statement = select(ResourceAuthor).where( + ResourceAuthor.resource_uuid == course_uuid, + ResourceAuthor.user_id == current_user.id + ) + resource_author = db_session.exec(statement).first() + + is_course_owner = False + if resource_author: + if ((resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or + (resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER) or + (resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR)) and \ + resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE: + is_course_owner = True + + # Check if user has admin or maintainer role + is_admin_or_maintainer = await authorization_verify_based_on_org_admin_status( + request, current_user.id, action, course_uuid, db_session + ) + + # SECURITY: For creating, updating, and deleting course content, user MUST be either: + # 1. Course owner (CREATOR, MAINTAINER, or CONTRIBUTOR with ACTIVE status) + # 2. Admin or maintainer role + # General role permissions are NOT sufficient for these actions + if not (is_course_owner or is_admin_or_maintainer): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"You must be the course owner (CREATOR, MAINTAINER, or CONTRIBUTOR) or have admin/maintainer role to {action} in this course", + ) + return True + else: + # For other actions, use the existing RBAC check + return await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_uuid, + db_session, + ) + + +async def courses_rbac_check_with_course_lookup( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, + require_course_ownership: bool = False, +) -> Course: + """ + Unified RBAC check for courses-related operations with course lookup. + + SECURITY NOTES: + - First validates that the course exists + - Then performs RBAC check using courses_rbac_check + - Returns the course object if authorized + + Args: + request: FastAPI request object + course_uuid: UUID of the course + current_user: Current user (PublicUser or AnonymousUser) + action: Action to perform (create, read, update, delete) + db_session: Database session + require_course_ownership: If True, requires course ownership for non-read actions + + Returns: + Course: The course object if authorized, raises HTTPException otherwise + + Raises: + HTTPException: 404 Not Found if course doesn't exist + HTTPException: 403 Forbidden if user lacks required permissions + """ + + # First check if course exists + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Perform RBAC check + await courses_rbac_check( + request, course_uuid, current_user, action, db_session, require_course_ownership + ) + + return course + + +async def courses_rbac_check_for_activities( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +) -> bool: + """ + Specialized RBAC check for activities that requires course ownership for non-read actions. + + SECURITY NOTES: + - Activities are core course content and require strict ownership controls + - READ: Allow if user has read access to the course + - CREATE/UPDATE/DELETE: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role + - This prevents unauthorized users from creating/modifying course activities + - Instructors can create courses but cannot create activities in courses they don't own + """ + + return await courses_rbac_check( + request, course_uuid, current_user, action, db_session, require_course_ownership=True + ) + + +async def courses_rbac_check_for_assignments( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +) -> bool: + """ + Specialized RBAC check for assignments that requires course ownership for non-read actions. + + SECURITY NOTES: + - Assignments are course content and require strict ownership controls + - READ: Allow if user has read access to the course + - CREATE/UPDATE/DELETE: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role + - This prevents unauthorized users from creating/modifying course assignments + - Instructors can create courses but cannot create assignments in courses they don't own + """ + + return await courses_rbac_check( + request, course_uuid, current_user, action, db_session, require_course_ownership=True + ) + + +async def courses_rbac_check_for_chapters( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +) -> bool: + """ + Specialized RBAC check for chapters that requires course ownership for non-read actions. + + SECURITY NOTES: + - Chapters are course structure and require strict ownership controls + - READ: Allow if user has read access to the course + - CREATE/UPDATE/DELETE: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role + - This prevents unauthorized users from creating/modifying course chapters + - Instructors can create courses but cannot create chapters in courses they don't own + """ + + return await courses_rbac_check( + request, course_uuid, current_user, action, db_session, require_course_ownership=True + ) + + +async def courses_rbac_check_for_certifications( + request: Request, + course_uuid: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +) -> bool: + """ + Specialized RBAC check for certifications that requires course ownership for non-read actions. + + SECURITY NOTES: + - Certifications are course credentials and require strict ownership controls + - READ: Allow if user has read access to the course + - CREATE/UPDATE/DELETE: Require course ownership (CREATOR, MAINTAINER, CONTRIBUTOR) or admin/maintainer role + - This prevents unauthorized users from creating/modifying course certifications + - CRITICAL: Without this check, users could create certifications for courses they don't own + - Instructors can create courses but cannot create certifications in courses they don't own + """ + + return await courses_rbac_check( + request, course_uuid, current_user, action, db_session, require_course_ownership=True + ) + + +async def courses_rbac_check_for_collections( + request: Request, + collection_uuid: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +) -> bool: + """ + Specialized RBAC check for collections. + + SECURITY NOTES: + - Collections are course groupings and require appropriate access controls + - READ: Allow if collection is public or user has read access + - CREATE/UPDATE/DELETE: Require appropriate permissions based on collection ownership + - Collections may have different ownership models than courses + + Args: + request: FastAPI request object + collection_uuid: UUID of the collection + current_user: Current user (PublicUser or AnonymousUser) + action: Action to perform (create, read, update, delete) + db_session: Database session + + Returns: + bool: True if authorized, raises HTTPException otherwise + """ + + if action == "read": + if current_user.id == 0: # Anonymous user + res = await authorization_verify_if_element_is_public( + request, collection_uuid, action, db_session + ) + if res == False: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User rights : You are not allowed to read this collection", + ) + return res + else: + return await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, action, collection_uuid, db_session + ) + else: + await authorization_verify_if_user_is_anon(current_user.id) + + return await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + collection_uuid, + db_session, + ) \ No newline at end of file diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index 42ad1f05..51a16987 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -6,15 +6,19 @@ from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, + authorization_verify_based_on_org_admin_status, + authorization_verify_based_on_roles, ) from src.db.courses.activities import ActivityCreate, Activity, ActivityRead, ActivityUpdate from src.db.courses.chapter_activities import ChapterActivity from src.db.users import AnonymousUser, PublicUser -from fastapi import HTTPException, Request +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum +from fastapi import HTTPException, Request, status from uuid import uuid4 from datetime import datetime from src.services.payments.payments_access import check_activity_paid_access +from src.security.courses_security import courses_rbac_check_for_activities #################################################### @@ -49,7 +53,7 @@ async def create_activity( detail="Course not found", ) - await rbac_check(request, course.course_uuid, current_user, "create", db_session) + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "create", db_session) # Create Activity activity = Activity(**activity_object.model_dump()) @@ -118,7 +122,7 @@ async def get_activity( activity, course = result # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "read", db_session) # Paid access check has_paid_access = await check_activity_paid_access( @@ -156,7 +160,7 @@ async def get_activityby_id( activity, course = result # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "read", db_session) return ActivityRead.model_validate(activity) @@ -187,7 +191,7 @@ async def update_activity( detail="Course not found", ) - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "update", db_session) # Update only the fields that were passed in for var, value in vars(activity_object).items(): @@ -228,7 +232,7 @@ async def delete_activity( detail="Course not found", ) - await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "delete", db_session) # Delete activity from chapter statement = select(ChapterActivity).where( @@ -296,46 +300,8 @@ async def get_activities( detail="Course not found", ) - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "read", db_session) activities = [ActivityRead.model_validate(activity) for activity in activities] return activities - - -## 🔒 RBAC Utils ## - - -async def rbac_check( - request: Request, - element_uuid: str, - current_user: PublicUser | AnonymousUser, - action: Literal["create", "read", "update", "delete"], - db_session: Session, -): - - - if action == "read": - if current_user.id == 0: # Anonymous user - res = await authorization_verify_if_element_is_public( - request, element_uuid, action, db_session - ) - return res - else: - res = await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, action, element_uuid, db_session - ) - return res - else: - # For non-read actions, proceed with regular RBAC checks - await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.id, - action, - element_uuid, - db_session, - ) - - -## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/activities/assignments.py b/apps/api/src/services/courses/activities/assignments.py index e936c800..624f2737 100644 --- a/apps/api/src/services/courses/activities/assignments.py +++ b/apps/api/src/services/courses/activities/assignments.py @@ -45,6 +45,7 @@ from src.services.courses.activities.uploads.tasks_ref_files import ( ) from src.services.trail.trail import check_trail_presence from src.services.courses.certifications import check_course_completion_and_create_certificate +from src.security.courses_security import courses_rbac_check_for_assignments ## > Assignments CRUD @@ -66,7 +67,7 @@ async def create_assignment( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "create", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "create", db_session) # Usage check check_limits_with_usage("assignments", course.org_id, db_session) @@ -118,7 +119,7 @@ async def read_assignment( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignment read return AssignmentRead.model_validate(assignment) @@ -161,7 +162,7 @@ async def read_assignment_from_activity_uuid( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignment read return AssignmentRead.model_validate(assignment) @@ -195,7 +196,7 @@ async def update_assignment( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session) # Update only the fields that were passed in for var, value in vars(assignment_object).items(): @@ -239,7 +240,7 @@ async def delete_assignment( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session) # Feature usage decrease_feature_usage("assignments", course.org_id, db_session) @@ -289,7 +290,7 @@ async def delete_assignment_from_activity_uuid( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session) # Feature usage decrease_feature_usage("assignments", course.org_id, db_session) @@ -333,7 +334,7 @@ async def create_assignment_task( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "create", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "create", db_session) # Create Assignment Task assignment_task = AssignmentTask(**assignment_task_object.model_dump()) @@ -388,7 +389,7 @@ async def read_assignment_tasks( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignment tasks read return [ @@ -436,7 +437,7 @@ async def read_assignment_task( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignment task read return AssignmentTaskRead.model_validate(assignmenttask) @@ -490,7 +491,7 @@ async def put_assignment_task_reference_file( org = db_session.exec(org_statement).first() # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session) # Upload reference file if reference_file and reference_file.filename and activity and org: @@ -568,7 +569,7 @@ async def put_assignment_task_submission_file( org = db_session.exec(org_statement).first() # RBAC check - only need read permission to submit files - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # Check if user is enrolled in the course if not await authorization_verify_based_on_roles(request, current_user.id, "read", course.course_uuid, db_session): @@ -633,7 +634,7 @@ async def update_assignment_task( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session) # Update only the fields that were passed in for var, value in vars(assignment_task_object).items(): @@ -689,7 +690,7 @@ async def delete_assignment_task( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session) # Delete Assignment Task db_session.delete(assignment_task) @@ -741,7 +742,7 @@ async def handle_assignment_task_submission( detail="Course not found", ) - # Check if user has instructor/admin permissions + # SECURITY: Check if user has instructor/admin permissions for grading is_instructor = await authorization_verify_based_on_roles(request, current_user.id, "update", course.course_uuid, db_session) # For regular users, ensure they can only submit their own work @@ -753,7 +754,7 @@ async def handle_assignment_task_submission( detail="You must be enrolled in this course to submit assignments" ) - # Regular users cannot update grades - only check if actual values are being set + # SECURITY: Regular users cannot update grades - only check if actual values are being set if (assignment_task_submission_object.grade is not None and assignment_task_submission_object.grade != 0) or \ (assignment_task_submission_object.task_submission_grade_feedback is not None and assignment_task_submission_object.task_submission_grade_feedback != ""): raise HTTPException( @@ -762,10 +763,10 @@ async def handle_assignment_task_submission( ) # Only need read permission for submissions - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) else: - # Instructors/admins need update permission to grade - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + # SECURITY: Instructors/admins need update permission to grade + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session) # Try to find existing submission if UUID is provided assignment_task_submission = None @@ -777,7 +778,7 @@ async def handle_assignment_task_submission( # If submission exists, update it if assignment_task_submission: - # For regular users, ensure they can only update their own submissions + # SECURITY: For regular users, ensure they can only update their own submissions if not is_instructor and assignment_task_submission.user_id != current_user.id: raise HTTPException( status_code=403, @@ -880,7 +881,7 @@ async def read_user_assignment_task_submissions( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignment task submission read return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission) @@ -953,7 +954,7 @@ async def read_assignment_task_submissions( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignment task submission read return AssignmentTaskSubmissionRead.model_validate(assignment_task_submission) @@ -1012,7 +1013,7 @@ async def update_assignment_task_submission( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # Update only the fields that were passed in for var, value in vars(assignment_task_submission_object).items(): @@ -1081,7 +1082,7 @@ async def delete_assignment_task_submission( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session) # Delete Assignment Task Submission db_session.delete(assignment_task_submission) @@ -1147,7 +1148,7 @@ async def create_assignment_submission( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # Create Assignment User Submission assignment_user_submission = AssignmentUserSubmission( @@ -1280,7 +1281,7 @@ async def read_assignment_submissions( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignment tasks read return [ @@ -1323,7 +1324,7 @@ async def read_user_assignment_submissions( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignment tasks read return [ @@ -1389,7 +1390,7 @@ async def update_assignment_submission( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # Update only the fields that were passed in for var, value in vars(assignment_user_submission_object).items(): @@ -1447,7 +1448,7 @@ async def delete_assignment_submission( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "delete", db_session) # Delete Assignment User Submission db_session.delete(assignment_user_submission) @@ -1464,7 +1465,7 @@ async def grade_assignment_submission( current_user: PublicUser | AnonymousUser, db_session: Session, ): - + # SECURITY: This function should only be accessible by course owners or instructors # Check if assignment exists statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) assignment = db_session.exec(statement).first() @@ -1484,7 +1485,8 @@ async def grade_assignment_submission( detail="Course not found", ) - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + # SECURITY: Require course ownership or instructor role for grading + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session) # Check if assignment user submission exists statement = select(AssignmentUserSubmission).where( @@ -1602,6 +1604,7 @@ async def mark_activity_as_done_for_user( current_user: PublicUser | AnonymousUser, db_session: Session, ): + # SECURITY: This function should only be accessible by course owners or instructors # Get Assignment statement = select(Assignment).where(Assignment.assignment_uuid == assignment_uuid) assignment = db_session.exec(statement).first() @@ -1625,7 +1628,8 @@ async def mark_activity_as_done_for_user( detail="Course not found", ) - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + # SECURITY: Require course ownership or instructor role for marking activities as done + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "update", db_session) if not activity: raise HTTPException( @@ -1704,46 +1708,7 @@ async def get_assignments_from_course( assignments.append(assignment) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_assignments(request, course.course_uuid, current_user, "read", db_session) # return assignments read return [AssignmentRead.model_validate(assignment) for assignment in assignments] - - -## 🔒 RBAC Utils ## - - -async def rbac_check( - request: Request, - course_uuid: str, - current_user: PublicUser | AnonymousUser, - action: Literal["create", "read", "update", "delete"], - db_session: Session, -): - - if action == "read": - if current_user.id == 0: # Anonymous user - res = await authorization_verify_if_element_is_public( - request, course_uuid, action, db_session - ) - return res - else: - res = ( - await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, action, course_uuid, db_session - ) - ) - return res - else: - await authorization_verify_if_user_is_anon(current_user.id) - - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.id, - action, - course_uuid, - db_session, - ) - - -## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/activities/pdf.py b/apps/api/src/services/courses/activities/pdf.py index b9fe563f..85d056ba 100644 --- a/apps/api/src/services/courses/activities/pdf.py +++ b/apps/api/src/services/courses/activities/pdf.py @@ -20,6 +20,7 @@ from src.services.courses.activities.uploads.pdfs import upload_pdf from fastapi import HTTPException, status, UploadFile, Request from uuid import uuid4 from datetime import datetime +from src.security.courses_security import courses_rbac_check_for_activities async def create_documentpdf_activity( @@ -30,9 +31,6 @@ async def create_documentpdf_activity( db_session: Session, pdf_file: UploadFile | None = None, ): - # RBAC check - await rbac_check(request, "course_uuid", current_user, "create", db_session) - # get chapter_id statement = select(Chapter).where(Chapter.id == chapter_id) chapter = db_session.exec(statement).first() @@ -52,6 +50,19 @@ async def create_documentpdf_activity( detail="CourseChapter not found", ) + # Get course_uuid for RBAC check + statement = select(Course).where(Course.id == coursechapter.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "create", db_session) + # get org_id org_id = coursechapter.org_id @@ -59,10 +70,6 @@ async def create_documentpdf_activity( statement = select(Organization).where(Organization.id == coursechapter.org_id) organization = db_session.exec(statement).first() - # Get course_uuid - statement = select(Course).where(Course.id == coursechapter.course_id) - course = db_session.exec(statement).first() - # create activity uuid activity_uuid = f"activity_{uuid4()}" @@ -119,7 +126,7 @@ async def create_documentpdf_activity( ) # upload pdf - if pdf_file: + if pdf_file and organization and course: # get pdffile format await upload_pdf( pdf_file, @@ -134,27 +141,3 @@ async def create_documentpdf_activity( db_session.refresh(activity_chapter) return ActivityRead.model_validate(activity) - - -## 🔒 RBAC Utils ## - - -async def rbac_check( - request: Request, - course_id: str, - current_user: PublicUser | AnonymousUser, - action: Literal["create", "read", "update", "delete"], - db_session: Session, -): - await authorization_verify_if_user_is_anon(current_user.id) - - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.id, - action, - course_id, - db_session, - ) - - -## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index dc7d3ab5..1dec5c5d 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -23,6 +23,7 @@ from src.services.courses.activities.uploads.videos import upload_video from fastapi import HTTPException, status, UploadFile, Request from uuid import uuid4 from datetime import datetime +from src.security.courses_security import courses_rbac_check_for_activities async def create_video_activity( @@ -34,9 +35,6 @@ async def create_video_activity( video_file: UploadFile | None = None, details: str = "{}", ): - # RBAC check - await rbac_check(request, "activity_x", current_user, "create", db_session) - # get chapter_id statement = select(Chapter).where(Chapter.id == chapter_id) chapter = db_session.exec(statement).first() @@ -59,14 +57,23 @@ async def create_video_activity( detail="CourseChapter not found", ) + # Get course_uuid for RBAC check + statement = select(Course).where(Course.id == coursechapter.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "create", db_session) + # Get org_uuid statement = select(Organization).where(Organization.id == coursechapter.org_id) organization = db_session.exec(statement).first() - # Get course_uuid - statement = select(Course).where(Course.id == coursechapter.course_id) - course = db_session.exec(statement).first() - # generate activity_uuid activity_uuid = str(f"activity_{uuid4()}") @@ -103,7 +110,7 @@ async def create_video_activity( "filename": "video." + video_format, "activity_uuid": activity_uuid, }, - details=details, + details=details if isinstance(details, dict) else json.loads(details), creation_date=str(datetime.now()), update_date=str(datetime.now()), ) @@ -115,7 +122,7 @@ async def create_video_activity( db_session.refresh(activity) # upload video - if video_file: + if video_file and organization and course: # get videofile format await upload_video( video_file, @@ -161,9 +168,6 @@ async def create_external_video_activity( data: ExternalVideo, db_session: Session, ): - # RBAC check - await rbac_check(request, "activity_x", current_user, "create", db_session) - # get chapter_id statement = select(Chapter).where(Chapter.id == data.chapter_id) chapter = db_session.exec(statement).first() @@ -183,6 +187,19 @@ async def create_external_video_activity( detail="CourseChapter not found", ) + # Get course_uuid for RBAC check + statement = select(Course).where(Course.id == coursechapter.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check + await courses_rbac_check_for_activities(request, course.course_uuid, current_user, "create", db_session) + # generate activity_uuid activity_uuid = str(f"activity_{uuid4()}") @@ -230,22 +247,4 @@ async def create_external_video_activity( return ActivityRead.model_validate(activity) -async def rbac_check( - request: Request, - course_id: str, - current_user: PublicUser | AnonymousUser, - action: Literal["create", "read", "update", "delete"], - db_session: Session, -): - await authorization_verify_if_user_is_anon(current_user.id) - - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.id, - action, - course_id, - db_session, - ) - - ## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/certifications.py b/apps/api/src/services/courses/certifications.py index af5a2b54..639fc27e 100644 --- a/apps/api/src/services/courses/certifications.py +++ b/apps/api/src/services/courses/certifications.py @@ -20,6 +20,7 @@ from src.security.rbac.rbac import ( authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) +from src.security.courses_security import courses_rbac_check_for_certifications #################################################### @@ -46,7 +47,7 @@ async def create_certification( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "create", db_session) + await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "create", db_session) # Create certification certification = Certifications( @@ -93,7 +94,7 @@ async def get_certification( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "read", db_session) return CertificationRead(**certification.model_dump()) @@ -117,7 +118,7 @@ async def get_certifications_by_course( ) # RBAC check - await rbac_check(request, course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_certifications(request, course_uuid, current_user, "read", db_session) # Get certifications for this course statement = select(Certifications).where(Certifications.course_id == course.id) @@ -155,7 +156,7 @@ async def update_certification( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "update", db_session) # Update only the fields that were passed in for var, value in vars(certification_object).items(): @@ -200,7 +201,7 @@ async def delete_certification( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "delete", db_session) db_session.delete(certification) db_session.commit() @@ -218,8 +219,16 @@ async def create_certificate_user( user_id: int, certification_id: int, db_session: Session, + current_user: PublicUser | AnonymousUser | None = None, ) -> CertificateUserRead: - """Create a certificate user link""" + """ + Create a certificate user link + + SECURITY NOTES: + - This function should only be called by authorized users (course owners, instructors, or system) + - When called from check_course_completion_and_create_certificate, it's a system operation + - When called directly, requires proper RBAC checks + """ # Check if certification exists statement = select(Certifications).where(Certifications.id == certification_id) @@ -231,6 +240,21 @@ async def create_certificate_user( detail="Certification not found", ) + # SECURITY: If current_user is provided, perform RBAC check + if current_user: + # Get course for RBAC check + statement = select(Course).where(Course.id == certification.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # Require course ownership or instructor role for creating certificates + await courses_rbac_check_for_certifications(request, course.course_uuid, current_user, "create", db_session) + # Check if certificate user already exists statement = select(CertificateUser).where( CertificateUser.user_id == user_id, @@ -316,7 +340,7 @@ async def get_user_certificates_for_course( ) # RBAC check - await rbac_check(request, course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_certifications(request, course_uuid, current_user, "read", db_session) # Get all certifications for this course statement = select(Certifications).where(Certifications.course_id == course.id) @@ -357,7 +381,14 @@ async def check_course_completion_and_create_certificate( course_id: int, db_session: Session, ) -> bool: - """Check if all activities in a course are completed and create certificate if so""" + """ + Check if all activities in a course are completed and create certificate if so + + SECURITY NOTES: + - This function is called by the system when activities are completed + - It should only create certificates for users who have actually completed the course + - The function is called from mark_activity_as_done_for_user which already has RBAC checks + """ # Get all activities in the course statement = select(ChapterActivity).where(ChapterActivity.course_id == course_id) @@ -381,7 +412,8 @@ async def check_course_completion_and_create_certificate( certification = db_session.exec(statement).first() if certification and certification.id: - # Create certificate user link + # SECURITY: Create certificate user link (system operation, no RBAC needed here) + # This is called from mark_activity_as_done_for_user which already has proper RBAC checks try: await create_certificate_user(request, user_id, certification.id, db_session) return True @@ -505,37 +537,4 @@ async def get_all_user_certificates( } if user else None }) - return result - - -#################################################### -# RBAC Utils -#################################################### - - -async def rbac_check( - request: Request, - course_uuid: str, - current_user: PublicUser | AnonymousUser, - action: Literal["create", "read", "update", "delete"], - db_session: Session, -): - if action == "read": - if current_user.id == 0: # Anonymous user - await authorization_verify_if_element_is_public( - request, course_uuid, action, db_session - ) - else: - await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, action, course_uuid, db_session - ) - else: - await authorization_verify_if_user_is_anon(current_user.id) - - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.id, - action, - course_uuid, - db_session, - ) \ No newline at end of file + return result \ No newline at end of file diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index 4a30bb3d..cbabc1c8 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import List, Literal from uuid import uuid4 from sqlmodel import Session, select -from src.db.users import AnonymousUser +from src.db.users import AnonymousUser, PublicUser from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, @@ -18,9 +18,9 @@ from src.db.courses.chapters import ( ChapterUpdate, ChapterUpdateOrder, ) -from src.services.courses.courses import Course -from src.services.users.users import PublicUser +from src.db.courses.courses import Course from fastapi import HTTPException, status, Request +from src.security.courses_security import courses_rbac_check_for_chapters #################################################### @@ -42,7 +42,7 @@ async def create_chapter( course = db_session.exec(statement).one() # RBAC check - await rbac_check(request, "chapter_x", current_user, "create", db_session) + await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "create", db_session) # complete chapter object chapter.course_id = chapter_object.course_id @@ -55,7 +55,7 @@ async def create_chapter( statement = ( select(CourseChapter) .where(CourseChapter.course_id == chapter.course_id) - .order_by(CourseChapter.order) + .order_by(CourseChapter.order) # type: ignore ) course_chapters = db_session.exec(statement).all() @@ -122,14 +122,14 @@ async def get_chapter( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "read", db_session) # Get activities for this chapter statement = ( select(Activity) - .join(ChapterActivity, Activity.id == ChapterActivity.activity_id) + .join(ChapterActivity, Activity.id == ChapterActivity.activity_id) # type: ignore .where(ChapterActivity.chapter_id == chapter_id) - .distinct(Activity.id) + .distinct(Activity.id) # type: ignore ) activities = db_session.exec(statement).all() @@ -158,7 +158,7 @@ async def update_chapter( ) # RBAC check - await rbac_check(request, chapter.chapter_uuid, current_user, "update", db_session) + await courses_rbac_check_for_chapters(request, chapter.chapter_uuid, current_user, "update", db_session) # Update only the fields that were passed in for var, value in vars(chapter_object).items(): @@ -193,7 +193,7 @@ async def delete_chapter( ) # RBAC check - await rbac_check(request, chapter.chapter_uuid, current_user, "delete", db_session) + await courses_rbac_check_for_chapters(request, chapter.chapter_uuid, current_user, "delete", db_session) # Remove all linked chapter activities statement = select(ChapterActivity).where(ChapterActivity.chapter_id == chapter.id) @@ -224,26 +224,26 @@ async def get_course_chapters( statement = ( select(Chapter) - .join(CourseChapter, Chapter.id == CourseChapter.chapter_id) + .join(CourseChapter, Chapter.id == CourseChapter.chapter_id) # type: ignore .where(CourseChapter.course_id == course_id) .where(Chapter.course_id == course_id) - .order_by(CourseChapter.order) - .group_by(Chapter.id, CourseChapter.order) + .order_by(CourseChapter.order) # type: ignore + .group_by(Chapter.id, CourseChapter.order) # type: ignore ) chapters = db_session.exec(statement).all() chapters = [ChapterRead(**chapter.model_dump(), activities=[]) for chapter in chapters] # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) # type: ignore + await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "read", db_session) # type: ignore # Get activities for each chapter for chapter in chapters: statement = ( select(ChapterActivity) .where(ChapterActivity.chapter_id == chapter.id) - .order_by(ChapterActivity.order) - .distinct(ChapterActivity.id, ChapterActivity.order) + .order_by(ChapterActivity.order) # type: ignore + .distinct(ChapterActivity.id, ChapterActivity.order) # type: ignore ) chapter_activities = db_session.exec(statement).all() @@ -251,7 +251,7 @@ async def get_course_chapters( statement = ( select(Activity) .where(Activity.id == chapter_activity.activity_id, with_unpublished_activities or Activity.published == True) - .distinct(Activity.id) + .distinct(Activity.id) # type: ignore ) activity = db_session.exec(statement).first() @@ -279,7 +279,7 @@ async def DEPRECEATED_get_course_chapters( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "read", db_session) chapters_in_db = await get_course_chapters(request, course.id, db_session, current_user) # type: ignore @@ -306,9 +306,9 @@ async def DEPRECEATED_get_course_chapters( activities_list = {} statement = ( select(Activity) - .join(ChapterActivity, ChapterActivity.activity_id == Activity.id) + .join(ChapterActivity, ChapterActivity.activity_id == Activity.id) # type: ignore .where(ChapterActivity.activity_id == Activity.id) - .group_by(Activity.id) + .group_by(Activity.id) # type: ignore ) activities_in_db = db_session.exec(statement).all() @@ -324,10 +324,10 @@ async def DEPRECEATED_get_course_chapters( # get chapter order statement = ( select(Chapter) - .join(CourseChapter, CourseChapter.chapter_id == Chapter.id) + .join(CourseChapter, CourseChapter.chapter_id == Chapter.id) # type: ignore .where(CourseChapter.chapter_id == Chapter.id) - .group_by(Chapter.id, CourseChapter.order) - .order_by(CourseChapter.order) + .group_by(Chapter.id, CourseChapter.order) # type: ignore + .order_by(CourseChapter.order) # type: ignore ) chapters_in_db = db_session.exec(statement).all() @@ -361,7 +361,7 @@ async def reorder_chapters_and_activities( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check_for_chapters(request, course.course_uuid, current_user, "update", db_session) ########### # Chapters @@ -458,39 +458,3 @@ async def reorder_chapters_and_activities( db_session.commit() return {"detail": "Chapters and activities reordered successfully"} - - -## 🔒 RBAC Utils ## - - -async def rbac_check( - request: Request, - course_uuid: str, - current_user: PublicUser | AnonymousUser, - action: Literal["create", "read", "update", "delete"], - db_session: Session, -): - if action == "read": - if current_user.id == 0: # Anonymous user - res = await authorization_verify_if_element_is_public( - request, course_uuid, action, db_session - ) - return res - else: - res = await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, action, course_uuid, db_session - ) - return res - else: - await authorization_verify_if_user_is_anon(current_user.id) - - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.id, - action, - course_uuid, - db_session, - ) - - -## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/collections.py b/apps/api/src/services/courses/collections.py index 774e2393..976f3eb9 100644 --- a/apps/api/src/services/courses/collections.py +++ b/apps/api/src/services/courses/collections.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import List, Literal from uuid import uuid4 from sqlmodel import Session, select -from src.db.users import AnonymousUser +from src.db.users import AnonymousUser, PublicUser from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, @@ -16,8 +16,8 @@ from src.db.collections import ( ) from src.db.collections_courses import CollectionCourse from src.db.courses.courses import Course -from src.services.users.users import PublicUser from fastapi import HTTPException, status, Request +from src.security.courses_security import courses_rbac_check_for_collections #################################################### @@ -40,7 +40,7 @@ async def get_collection( ) # RBAC check - await rbac_check( + await courses_rbac_check_for_collections( request, collection.collection_uuid, current_user, "read", db_session ) @@ -86,8 +86,10 @@ async def create_collection( ) -> CollectionRead: collection = Collection.model_validate(collection_object) - # RBAC check - await rbac_check(request, "collection_x", current_user, "create", db_session) + # SECURITY: Check if user has permission to create collections in this organization + # Since collections are organization-level resources, we need to check org permissions + # For now, we'll use the existing RBAC check but with proper organization context + await courses_rbac_check_for_collections(request, "collection_x", current_user, "create", db_session) # Complete the collection object collection.collection_uuid = f"collection_{uuid4()}" @@ -99,18 +101,32 @@ async def create_collection( db_session.commit() db_session.refresh(collection) - # Link courses to collection + # SECURITY: Link courses to collection - ensure user has access to all courses being added if collection: for course_id in collection_object.courses: - collection_course = CollectionCourse( - collection_id=int(collection.id), # type: ignore - course_id=course_id, - org_id=int(collection_object.org_id), - creation_date=str(datetime.now()), - update_date=str(datetime.now()), - ) - # Add collection_course to database - db_session.add(collection_course) + # Check if user has access to this course + statement = select(Course).where(Course.id == course_id) + course = db_session.exec(statement).first() + + if course: + # Verify user has read access to the course before adding it to collection + try: + await courses_rbac_check_for_collections(request, course.course_uuid, current_user, "read", db_session) + except HTTPException: + raise HTTPException( + status_code=403, + detail=f"You don't have permission to add course {course.name} to this collection" + ) + + collection_course = CollectionCourse( + collection_id=int(collection.id), # type: ignore + course_id=course_id, + org_id=int(collection_object.org_id), + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + # Add collection_course to database + db_session.add(collection_course) db_session.commit() db_session.refresh(collection) @@ -145,7 +161,7 @@ async def update_collection( ) # RBAC check - await rbac_check( + await courses_rbac_check_for_collections( request, collection.collection_uuid, current_user, "update", db_session ) @@ -219,7 +235,7 @@ async def delete_collection( ) # RBAC check - await rbac_check( + await courses_rbac_check_for_collections( request, collection.collection_uuid, current_user, "delete", db_session ) @@ -248,7 +264,7 @@ async def get_collections( Collection.org_id == org_id, Collection.public == True ) statement_all = ( - select(Collection).where(Collection.org_id == org_id).distinct(Collection.id) + select(Collection).where(Collection.org_id == org_id).distinct(Collection.id) # type: ignore ) if current_user.id == 0: @@ -288,49 +304,7 @@ async def get_collections( courses = db_session.exec(statement).all() - collection = CollectionRead(**collection.model_dump(), courses=courses) + collection = CollectionRead(**collection.model_dump(), courses=list(courses)) collections_with_courses.append(collection) return collections_with_courses - - -## 🔒 RBAC Utils ## - - -async def rbac_check( - request: Request, - collection_uuid: str, - current_user: PublicUser | AnonymousUser, - action: Literal["create", "read", "update", "delete"], - db_session: Session, -): - if action == "read": - if current_user.id == 0: # Anonymous user - res = await authorization_verify_if_element_is_public( - request, collection_uuid, action, db_session - ) - if res == False: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User rights : You are not allowed to read this collection", - ) - else: - res = ( - await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, action, collection_uuid, db_session - ) - ) - return res - else: - await authorization_verify_if_user_is_anon(current_user.id) - - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.id, - action, - collection_uuid, - db_session, - ) - - -## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/contributors.py b/apps/api/src/services/courses/contributors.py index b055c901..b0464fcc 100644 --- a/apps/api/src/services/courses/contributors.py +++ b/apps/api/src/services/courses/contributors.py @@ -4,7 +4,8 @@ from sqlmodel import Session, select, and_ from src.db.users import PublicUser, AnonymousUser, User, UserRead from src.db.courses.courses import Course from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum -from src.security.rbac.rbac import authorization_verify_if_user_is_anon, authorization_verify_based_on_roles_and_authorship +from src.security.rbac.rbac import authorization_verify_if_user_is_anon, authorization_verify_based_on_org_admin_status +from src.security.courses_security import courses_rbac_check from typing import List @@ -14,6 +15,14 @@ async def apply_course_contributor( current_user: PublicUser | AnonymousUser, db_session: Session, ): + """ + Apply to become a course contributor + + SECURITY NOTES: + - Any authenticated user can apply to become a contributor + - Applications are created with PENDING status + - Only course owners (CREATOR, MAINTAINER) or admins can approve applications + """ # Verify user is not anonymous await authorization_verify_if_user_is_anon(current_user.id) @@ -73,21 +82,17 @@ async def update_course_contributor( ): """ Update a course contributor's role and status - Only administrators can perform this action + + SECURITY NOTES: + - Only course owners (CREATOR, MAINTAINER) or admins can update contributors + - Cannot modify the role of the course creator + - Requires strict course ownership checks """ # Verify user is not anonymous await authorization_verify_if_user_is_anon(current_user.id) - # RBAC check - verify if user has admin rights - authorized = await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, "update", course_uuid, db_session - ) - - if not authorized: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You are not authorized to update course contributors", - ) + # SECURITY: Require course ownership or admin role for updating contributors + await courses_rbac_check(request, course_uuid, current_user, "update", db_session) # Check if course exists statement = select(Course).where(Course.course_uuid == course_uuid) @@ -115,7 +120,7 @@ async def update_course_contributor( detail="Contributor not found for this course", ) - # Don't allow changing the role of the creator + # SECURITY: Don't allow changing the role of the creator if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR: raise HTTPException( status_code=400, @@ -144,6 +149,10 @@ async def get_course_contributors( ) -> List[dict]: """ Get all contributors for a course with their user information + + SECURITY NOTES: + - Requires read access to the course + - Contributors are visible to anyone with course read access """ # Check if course exists statement = select(Course).where(Course.course_uuid == course_uuid) @@ -155,6 +164,9 @@ async def get_course_contributors( detail="Course not found", ) + # SECURITY: Require read access to the course + await courses_rbac_check(request, course_uuid, current_user, "read", db_session) + # Get all contributors for this course with user information statement = ( select(ResourceAuthor, User) @@ -184,21 +196,17 @@ async def add_bulk_course_contributors( ): """ Add multiple contributors to a course by their usernames - Only administrators can perform this action + + SECURITY NOTES: + - Only course owners (CREATOR, MAINTAINER) or admins can add contributors + - Requires strict course ownership checks + - Cannot add contributors to courses the user doesn't own """ # Verify user is not anonymous await authorization_verify_if_user_is_anon(current_user.id) - # RBAC check - verify if user has admin rights - authorized = await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, "update", course_uuid, db_session - ) - - if not authorized: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You are not authorized to add contributors", - ) + # SECURITY: Require course ownership or admin role for adding contributors + await courses_rbac_check(request, course_uuid, current_user, "update", db_session) # Check if course exists statement = select(Course).where(Course.course_uuid == course_uuid) @@ -284,21 +292,18 @@ async def remove_bulk_course_contributors( ): """ Remove multiple contributors from a course by their usernames - Only administrators can perform this action + + SECURITY NOTES: + - Only course owners (CREATOR, MAINTAINER) or admins can remove contributors + - Requires strict course ownership checks + - Cannot remove contributors from courses the user doesn't own + - Cannot remove the course creator """ # Verify user is not anonymous await authorization_verify_if_user_is_anon(current_user.id) - # RBAC check - verify if user has admin rights - authorized = await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, "update", course_uuid, db_session - ) - - if not authorized: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You are not authorized to remove contributors", - ) + # SECURITY: Require course ownership or admin role for removing contributors + await courses_rbac_check(request, course_uuid, current_user, "update", db_session) # Check if course exists statement = select(Course).where(Course.course_uuid == course_uuid) @@ -346,7 +351,7 @@ async def remove_bulk_course_contributors( }) continue - # Don't allow removing the creator + # SECURITY: Don't allow removing the creator if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR: results["failed"].append({ "username": username, diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 98030597..ba5b449a 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -24,10 +24,12 @@ from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, + authorization_verify_based_on_org_admin_status, ) from src.services.courses.thumbnails import upload_thumbnail -from fastapi import HTTPException, Request, UploadFile +from fastapi import HTTPException, Request, UploadFile, status from datetime import datetime +from src.security.courses_security import courses_rbac_check async def get_course( @@ -46,15 +48,15 @@ async def get_course( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check(request, course.course_uuid, current_user, "read", db_session) # Get course authors with their roles authors_statement = ( select(ResourceAuthor, User) - .join(User, ResourceAuthor.user_id == User.id) + .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid == course.course_uuid) .order_by( - ResourceAuthor.id.asc() + ResourceAuthor.id.asc() # type: ignore ) ) author_results = db_session.exec(authors_statement).all() @@ -92,15 +94,15 @@ async def get_course_by_id( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check(request, course.course_uuid, current_user, "read", db_session) # Get course authors with their roles authors_statement = ( select(ResourceAuthor, User) - .join(User, ResourceAuthor.user_id == User.id) + .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid == course.course_uuid) .order_by( - ResourceAuthor.id.asc() + ResourceAuthor.id.asc() # type: ignore ) ) author_results = db_session.exec(authors_statement).all() @@ -153,7 +155,7 @@ async def get_course_meta( author_results = [(ra, u) for _, ra, u in results if ra is not None and u is not None] # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await courses_rbac_check(request, course.course_uuid, current_user, "read", db_session) # Get course chapters chapters = [] @@ -241,7 +243,7 @@ async def get_courses_orgslug( .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid.in_(course_uuids)) # type: ignore .order_by( - ResourceAuthor.id.asc() + ResourceAuthor.id.asc() # type: ignore ) ) @@ -349,10 +351,10 @@ async def search_courses( # Get course authors with their roles authors_statement = ( select(ResourceAuthor, User) - .join(User, ResourceAuthor.user_id == User.id) + .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid == course.course_uuid) .order_by( - ResourceAuthor.id.asc() + ResourceAuthor.id.asc() # type: ignore ) ) author_results = db_session.exec(authors_statement).all() @@ -399,10 +401,20 @@ async def create_course( thumbnail_file: UploadFile | None = None, thumbnail_type: ThumbnailType = ThumbnailType.IMAGE, ): + """ + Create a new course + + SECURITY NOTES: + - Requires proper permissions to create courses in the organization + - User becomes the CREATOR of the course automatically + - Course creation is subject to organization limits and permissions + """ course = Course.model_validate(course_object) - # RBAC check - await rbac_check(request, "course_x", current_user, "create", db_session) + # SECURITY: Check if user has permission to create courses in this organization + # Since this is a new course, we need to check organization-level permissions + # For now, we'll use the existing RBAC check but with proper organization context + await courses_rbac_check(request, "course_x", current_user, "create", db_session) # Usage check check_limits_with_usage("courses", org_id, db_session) @@ -440,7 +452,7 @@ async def create_course( db_session.commit() db_session.refresh(course) - # Make the user the creator of the course + # SECURITY: Make the user the creator of the course resource_author = ResourceAuthor( resource_uuid=course.course_uuid, user_id=current_user.id, @@ -458,10 +470,10 @@ async def create_course( # Get course authors with their roles authors_statement = ( select(ResourceAuthor, User) - .join(User, ResourceAuthor.user_id == User.id) + .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid == course.course_uuid) .order_by( - ResourceAuthor.id.asc() + ResourceAuthor.id.asc() # type: ignore ) ) author_results = db_session.exec(authors_statement).all() @@ -506,7 +518,7 @@ async def update_course_thumbnail( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session) # Get org uuid org_statement = select(Organization).where(Organization.id == course.org_id) @@ -543,10 +555,10 @@ async def update_course_thumbnail( # Get course authors with their roles authors_statement = ( select(ResourceAuthor, User) - .join(User, ResourceAuthor.user_id == User.id) + .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid == course.course_uuid) .order_by( - ResourceAuthor.id.asc() + ResourceAuthor.id.asc() # type: ignore ) ) author_results = db_session.exec(authors_statement).all() @@ -575,6 +587,14 @@ async def update_course( current_user: PublicUser | AnonymousUser, db_session: Session, ): + """ + Update a course + + SECURITY NOTES: + - Requires course ownership (CREATOR, MAINTAINER) or admin role + - Sensitive fields (public, open_to_contributors) require additional validation + - Cannot change course access settings without proper permissions + """ statement = select(Course).where(Course.course_uuid == course_uuid) course = db_session.exec(statement).first() @@ -584,8 +604,46 @@ async def update_course( detail="Course not found", ) - # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + # SECURITY: Require course ownership or admin role for updating courses + await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # SECURITY: Additional checks for sensitive access control fields + sensitive_fields_updated = [] + + # Check if sensitive fields are being updated + if course_object.public is not None: + sensitive_fields_updated.append("public") + if course_object.open_to_contributors is not None: + sensitive_fields_updated.append("open_to_contributors") + + # If sensitive fields are being updated, require additional validation + if sensitive_fields_updated: + # SECURITY: For sensitive access control changes, require CREATOR or MAINTAINER role + # Check if user is course owner (CREATOR or MAINTAINER) + statement = select(ResourceAuthor).where( + ResourceAuthor.resource_uuid == course_uuid, + ResourceAuthor.user_id == current_user.id + ) + resource_author = db_session.exec(statement).first() + + is_course_owner = False + if resource_author: + if ((resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or + (resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER)) and \ + resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE: + is_course_owner = True + + # Check if user has admin or maintainer role + is_admin_or_maintainer = await authorization_verify_based_on_org_admin_status( + request, current_user.id, "update", course_uuid, db_session + ) + + # SECURITY: Only course owners (CREATOR, MAINTAINER) or admins can change access settings + if not (is_course_owner or is_admin_or_maintainer): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"You must be the course owner (CREATOR or MAINTAINER) or have admin role to change access settings: {', '.join(sensitive_fields_updated)}", + ) # Update only the fields that were passed in for var, value in vars(course_object).items(): @@ -602,10 +660,10 @@ async def update_course( # Get course authors with their roles authors_statement = ( select(ResourceAuthor, User) - .join(User, ResourceAuthor.user_id == User.id) + .join(User, ResourceAuthor.user_id == User.id) # type: ignore .where(ResourceAuthor.resource_uuid == course.course_uuid) .order_by( - ResourceAuthor.id.asc() + ResourceAuthor.id.asc() # type: ignore ) ) author_results = db_session.exec(authors_statement).all() @@ -643,7 +701,7 @@ async def delete_course( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + await courses_rbac_check(request, course.course_uuid, current_user, "delete", db_session) # Feature usage decrease_feature_usage("courses", course.org_id, db_session) @@ -681,7 +739,7 @@ async def get_user_courses( return [] # Get courses with the extracted UUIDs - statement = select(Course).where(Course.course_uuid.in_(course_uuids)) + statement = select(Course).where(Course.course_uuid.in_(course_uuids)) # type: ignore # Apply pagination statement = statement.offset((page - 1) * limit).limit(limit) @@ -738,39 +796,177 @@ async def get_user_courses( return result -## 🔒 RBAC Utils ## - - -async def rbac_check( +async def get_course_user_rights( request: Request, course_uuid: str, current_user: PublicUser | AnonymousUser, - action: Literal["create", "read", "update", "delete"], db_session: Session, -): - if action == "read": - if current_user.id == 0: # Anonymous user - res = await authorization_verify_if_element_is_public( - request, course_uuid, action, db_session - ) - return res - else: - res = ( - await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, action, course_uuid, db_session - ) - ) - return res - else: - await authorization_verify_if_user_is_anon(current_user.id) +) -> dict: + """ + Get detailed user rights for a specific course. + + This function returns comprehensive rights information that can be used + by the UI to enable/disable features based on user permissions. + + SECURITY NOTES: + - Returns rights based on course ownership and user roles + - Includes both course-level and content-level permissions + - Safe to expose to UI as it only returns permission information + """ + # Check if course exists + statement = select(Course).where(Course.course_uuid == course_uuid) + course = db_session.exec(statement).first() - await authorization_verify_based_on_roles_and_authorship( - request, - current_user.id, - action, - course_uuid, - db_session, + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", ) + # Initialize rights object + rights = { + "course_uuid": course_uuid, + "user_id": current_user.id, + "is_anonymous": current_user.id == 0, + "permissions": { + "read": False, + "create": False, + "update": False, + "delete": False, + "create_content": False, + "update_content": False, + "delete_content": False, + "manage_contributors": False, + "manage_access": False, + "grade_assignments": False, + "mark_activities_done": False, + "create_certifications": False, + }, + "ownership": { + "is_owner": False, + "is_creator": False, + "is_maintainer": False, + "is_contributor": False, + "authorship_status": None, + }, + "roles": { + "is_admin": False, + "is_maintainer_role": False, + "is_instructor": False, + "is_user": False, + } + } -## 🔒 RBAC Utils ## + # Handle anonymous users + if current_user.id == 0: + # Anonymous users can only read public courses + if course.public: + rights["permissions"]["read"] = True + return rights + + # Check course ownership + statement = select(ResourceAuthor).where( + ResourceAuthor.resource_uuid == course_uuid, + ResourceAuthor.user_id == current_user.id + ) + resource_author = db_session.exec(statement).first() + + if resource_author: + rights["ownership"]["authorship_status"] = resource_author.authorship_status + + if resource_author.authorship_status == ResourceAuthorshipStatusEnum.ACTIVE: + if resource_author.authorship == ResourceAuthorshipEnum.CREATOR: + rights["ownership"]["is_creator"] = True + rights["ownership"]["is_owner"] = True + elif resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER: + rights["ownership"]["is_maintainer"] = True + rights["ownership"]["is_owner"] = True + elif resource_author.authorship == ResourceAuthorshipEnum.CONTRIBUTOR: + rights["ownership"]["is_contributor"] = True + rights["ownership"]["is_owner"] = True + + # Check user roles + from src.security.rbac.rbac import authorization_verify_based_on_org_admin_status + from src.security.rbac.rbac import authorization_verify_based_on_roles + + # Check admin/maintainer role + is_admin_or_maintainer = await authorization_verify_based_on_org_admin_status( + request, current_user.id, "update", course_uuid, db_session + ) + + if is_admin_or_maintainer: + rights["roles"]["is_admin"] = True + rights["roles"]["is_maintainer_role"] = True + + # Check instructor role + has_instructor_permissions = await authorization_verify_based_on_roles( + request, current_user.id, "create", "course_x", db_session + ) + + if has_instructor_permissions: + rights["roles"]["is_instructor"] = True + + # Check user role (basic permissions) + has_user_permissions = await authorization_verify_based_on_roles( + request, current_user.id, "read", course_uuid, db_session + ) + + if has_user_permissions: + rights["roles"]["is_user"] = True + + # Determine permissions based on ownership and roles + is_course_owner = rights["ownership"]["is_owner"] + is_admin = rights["roles"]["is_admin"] + is_maintainer_role = rights["roles"]["is_maintainer_role"] + is_instructor = rights["roles"]["is_instructor"] + + # READ permissions + if course.public or is_course_owner or is_admin or is_maintainer_role or is_instructor or has_user_permissions: + rights["permissions"]["read"] = True + + # CREATE permissions (course creation) + if is_instructor or is_admin or is_maintainer_role: + rights["permissions"]["create"] = True + + # UPDATE permissions (course-level updates) + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["update"] = True + + # DELETE permissions (course deletion) + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["delete"] = True + + # CONTENT CREATION permissions (activities, assignments, chapters, etc.) + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["create_content"] = True + + # CONTENT UPDATE permissions + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["update_content"] = True + + # CONTENT DELETE permissions + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["delete_content"] = True + + # CONTRIBUTOR MANAGEMENT permissions + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["manage_contributors"] = True + + # ACCESS MANAGEMENT permissions (public, open_to_contributors) + if (rights["ownership"]["is_creator"] or rights["ownership"]["is_maintainer"] or + is_admin or is_maintainer_role): + rights["permissions"]["manage_access"] = True + + # GRADING permissions + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["grade_assignments"] = True + + # ACTIVITY MARKING permissions + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["mark_activities_done"] = True + + # CERTIFICATION permissions + if is_course_owner or is_admin or is_maintainer_role: + rights["permissions"]["create_certifications"] = True + + return rights diff --git a/apps/api/src/services/courses/updates.py b/apps/api/src/services/courses/updates.py index f3fea858..19a2324f 100644 --- a/apps/api/src/services/courses/updates.py +++ b/apps/api/src/services/courses/updates.py @@ -12,7 +12,7 @@ from src.db.courses.course_updates import ( from src.db.courses.courses import Course from src.db.organizations import Organization from src.db.users import AnonymousUser, PublicUser -from src.services.courses.courses import rbac_check +from src.security.courses_security import courses_rbac_check async def create_update( @@ -41,7 +41,7 @@ async def create_update( ) # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session) # Generate UUID courseupdate_uuid = str(f"courseupdate_{uuid4()}") @@ -81,7 +81,7 @@ async def update_update( ) # RBAC check - await rbac_check( + await courses_rbac_check( request, update.courseupdate_uuid, current_user, "update", db_session ) @@ -115,7 +115,7 @@ async def delete_update( ) # RBAC check - await rbac_check( + await courses_rbac_check( request, update.courseupdate_uuid, current_user, "delete", db_session ) diff --git a/apps/api/src/services/payments/payments_courses.py b/apps/api/src/services/payments/payments_courses.py index 1382e408..45bf73c2 100644 --- a/apps/api/src/services/payments/payments_courses.py +++ b/apps/api/src/services/payments/payments_courses.py @@ -4,7 +4,7 @@ from src.db.payments.payments_courses import PaymentsCourse from src.db.payments.payments_products import PaymentsProduct from src.db.courses.courses import Course from src.db.users import PublicUser, AnonymousUser -from src.services.courses.courses import rbac_check +from src.security.courses_security import courses_rbac_check async def link_course_to_product( request: Request, @@ -22,7 +22,7 @@ async def link_course_to_product( raise HTTPException(status_code=404, detail="Course not found") # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session) # Check if product exists statement = select(PaymentsProduct).where( @@ -71,7 +71,7 @@ async def unlink_course_from_product( raise HTTPException(status_code=404, detail="Course not found") # RBAC check - await rbac_check(request, course.course_uuid, current_user, "update", db_session) + await courses_rbac_check(request, course.course_uuid, current_user, "update", db_session) # Find and delete the payment course link statement = select(PaymentsCourse).where( diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/client.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/client.tsx index 766c978b..cb5ab797 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/client.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/client.tsx @@ -8,6 +8,10 @@ import Modal from '@components/Objects/StyledElements/Modal/Modal' import { useSearchParams } from 'next/navigation' import React from 'react' import useAdminStatus from '@components/Hooks/useAdminStatus' +import { getUriWithOrg } from '@services/config/config' +import { useOrg } from '@components/Contexts/OrgContext' +import { BookOpen } from 'lucide-react' +import Link from 'next/link' type CourseProps = { orgslug: string @@ -22,6 +26,7 @@ function CoursesHome(params: CourseProps) { const orgslug = params.orgslug const courses = params.courses const isUserAdmin = useAdminStatus() as any + const org = useOrg() as any async function closeNewCourseModal() { setNewCourseModal(false) @@ -32,7 +37,16 @@ function CoursesHome(params: CourseProps) {
You don't have permission to access this course.
++ Understanding LearnHouse permissions, roles, and access controls based on RBAC system +
+{role.description}
+{type.description}
+{session.data.user.username}
- {isUserAdmin.isAdmin &&{session.data.user.username}
+{session.data.user.email}
+