feat: implement comprehensive RBAC checks for courses, chapters, collections, and activities, enhancing user rights management and security documentation

This commit is contained in:
swve 2025-08-09 12:13:12 +02:00
parent 887046203e
commit 3ce019abec
22 changed files with 1788 additions and 598 deletions

View file

@ -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)

View file

@ -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,
)

View file

@ -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 ##

View file

@ -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 ##

View file

@ -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 ##

View file

@ -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 ##

View file

@ -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
@ -506,36 +538,3 @@ async def get_all_user_certificates(
})
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,
)

View file

@ -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 ##

View file

@ -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,9 +101,23 @@ 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:
# 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,
@ -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 ##

View file

@ -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,

View file

@ -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.
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_uuid,
db_session,
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()
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

View file

@ -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
)

View file

@ -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(

View file

@ -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) {
<div className="mb-6">
<BreadCrumbs type="courses" />
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mt-4">
<div className="flex items-center space-x-4">
<h1 className="text-3xl font-bold mb-4 sm:mb-0">Courses</h1>
<Link
href={getUriWithOrg(org?.slug, '/dash/documentation/rights')}
className="rounded-lg bg-black hover:scale-105 transition-all duration-100 ease-linear antialiased p-2 px-5 font text-xs font-bold text-white drop-shadow-lg flex space-x-2 items-center"
>
<BookOpen className="w-4 h-4" />
<span>Rights Guide</span>
</Link>
</div>
<AuthenticatedClientElement
checkMethod="roles"
action="create"

View file

@ -1,16 +1,20 @@
'use client'
import { getUriWithOrg } from '@services/config/config'
import React, { use } from 'react';
import React, { use, useEffect } from 'react';
import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext'
import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
import { motion } from 'framer-motion'
import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users, Award } from 'lucide-react'
import { GalleryVerticalEnd, Globe, Info, UserPen, UserRoundCog, Users, Award, Lock } from 'lucide-react'
import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure'
import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
import EditCourseContributors from '@components/Dashboard/Pages/Course/EditCourseContributors/EditCourseContributors'
import EditCourseCertification from '@components/Dashboard/Pages/Course/EditCourseCertification/EditCourseCertification'
import { useCourseRights } from '@hooks/useCourseRights'
import { useRouter } from 'next/navigation'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
export type CourseOverviewParams = {
orgslug: string
courseuuid: string
@ -19,110 +23,146 @@ export type CourseOverviewParams = {
function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
const params = use(props.params);
const router = useRouter();
function getEntireCourseUUID(courseuuid: string) {
// add course_ to uuid
return `course_${courseuuid}`
}
const courseuuid = getEntireCourseUUID(params.courseuuid)
const { hasPermission, isLoading: rightsLoading } = useCourseRights(courseuuid)
// Define tab configurations with their required permissions
const tabs = [
{
key: 'general',
label: 'General',
icon: Info,
href: `/dash/courses/course/${params.courseuuid}/general`,
requiredPermission: 'update' as const
},
{
key: 'content',
label: 'Content',
icon: GalleryVerticalEnd,
href: `/dash/courses/course/${params.courseuuid}/content`,
requiredPermission: 'update_content' as const
},
{
key: 'access',
label: 'Access',
icon: Globe,
href: `/dash/courses/course/${params.courseuuid}/access`,
requiredPermission: 'manage_access' as const
},
{
key: 'contributors',
label: 'Contributors',
icon: UserPen,
href: `/dash/courses/course/${params.courseuuid}/contributors`,
requiredPermission: 'manage_contributors' as const
},
{
key: 'certification',
label: 'Certification',
icon: Award,
href: `/dash/courses/course/${params.courseuuid}/certification`,
requiredPermission: 'create_certifications' as const
}
]
// Filter tabs based on permissions
const visibleTabs = tabs.filter(tab => hasPermission(tab.requiredPermission))
// Check if current subpage is accessible
const currentTab = tabs.find(tab => tab.key === params.subpage)
const hasAccessToCurrentPage = currentTab ? hasPermission(currentTab.requiredPermission) : false
// Redirect to first available tab if current page is not accessible
useEffect(() => {
if (!rightsLoading && !hasAccessToCurrentPage && visibleTabs.length > 0) {
const firstAvailableTab = visibleTabs[0]
router.replace(getUriWithOrg(params.orgslug, '') + firstAvailableTab.href)
}
}, [rightsLoading, hasAccessToCurrentPage, visibleTabs, router, params.orgslug])
// Show loading state while rights are being fetched
if (rightsLoading) {
return (
<div className="h-screen w-full bg-[#f8f8f8] flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
</div>
)
}
// Show access denied if no tabs are available
if (!rightsLoading && visibleTabs.length === 0) {
return (
<div className="h-screen w-full bg-[#f8f8f8] flex items-center justify-center">
<div className="text-center">
<Lock className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Access Denied</h3>
<p className="text-gray-500">You don't have permission to access this course.</p>
</div>
</div>
)
}
return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]">
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)} withUnpublishedActivities={true}>
<CourseProvider courseuuid={courseuuid} withUnpublishedActivities={true}>
<div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
<CourseOverviewTop params={params} />
<div className="flex space-x-3 font-black text-sm">
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/general`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Info size={16} />
<div>General</div>
</div>
</div>
</Link>
{tabs.map((tab) => {
const IconComponent = tab.icon
const isActive = params.subpage.toString() === tab.key
const hasAccess = hasPermission(tab.requiredPermission)
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/content`
if (!hasAccess) {
// Show disabled tab with subtle visual cues and tooltip
return (
<ToolTip
key={tab.key}
content={
<div className="text-center">
<div className="font-medium text-gray-900">Access Restricted</div>
<div className="text-sm text-gray-600">
You don't have permission to access {tab.label}
</div>
</div>
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'content'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear opacity-30 cursor-not-allowed">
<div className="flex items-center space-x-2.5 mx-2">
<GalleryVerticalEnd size={16} />
<div>Content</div>
<IconComponent size={16} />
<div>{tab.label}</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/access`
</ToolTip>
)
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'access'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Globe size={16} />
<div>Access</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/contributors`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'contributors'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserPen size={16} />
<div>Contributors</div>
</div>
</div>
</Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/certification`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'certification'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Award size={16} />
<div>Certification</div>
</div>
</div>
</Link>
</div>
return (
<Link
key={tab.key}
href={getUriWithOrg(params.orgslug, '') + tab.href}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${
isActive ? 'border-b-4' : 'opacity-50 hover:opacity-75'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<IconComponent size={16} />
<div>{tab.label}</div>
</div>
</div>
</Link>
)
})}
</div>
</div>
<motion.div
initial={{ opacity: 0 }}
@ -132,12 +172,21 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
className="h-full overflow-y-auto relative"
>
<div className="absolute inset-0">
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
{params.subpage == 'contributors' ? (<EditCourseContributors orgslug={params.orgslug} />) : ('')}
{params.subpage == 'certification' ? (<EditCourseCertification orgslug={params.orgslug} />) : ('')}
{params.subpage == 'content' && hasPermission('update_content') ? (
<EditCourseStructure orgslug={params.orgslug} />
) : null}
{params.subpage == 'general' && hasPermission('update') ? (
<EditCourseGeneral orgslug={params.orgslug} />
) : null}
{params.subpage == 'access' && hasPermission('manage_access') ? (
<EditCourseAccess orgslug={params.orgslug} />
) : null}
{params.subpage == 'contributors' && hasPermission('manage_contributors') ? (
<EditCourseContributors orgslug={params.orgslug} />
) : null}
{params.subpage == 'certification' && hasPermission('create_certifications') ? (
<EditCourseCertification orgslug={params.orgslug} />
) : null}
</div>
</motion.div>
</CourseProvider>

View file

@ -0,0 +1,9 @@
import React from 'react'
export default function DocumentationLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View file

@ -0,0 +1,217 @@
'use client'
import React from 'react'
import { getUriWithOrg } from '@services/config/config'
import { useOrg } from '@components/Contexts/OrgContext'
import {
Shield,
Users,
BookOpen,
UserCheck,
Lock,
Globe,
Award,
FileText,
Settings,
Crown,
User,
UserCog,
GraduationCap,
Eye,
Edit,
Trash2,
Plus,
CheckCircle,
XCircle,
AlertCircle,
Info,
ArrowLeft,
AlertTriangle,
Key,
UserCheck as UserCheckIcon
} from 'lucide-react'
import Link from 'next/link'
import { motion } from 'framer-motion'
interface RightsDocumentationProps {
params: Promise<{ orgslug: string }>
}
const RightsDocumentation = ({ params }: RightsDocumentationProps) => {
const org = useOrg() as any
const roleHierarchy = [
{
name: 'Admin',
icon: <Crown className="w-6 h-6 text-purple-600" />,
color: 'bg-purple-50 border-purple-200',
description: 'Full platform control with all permissions',
permissions: ['All permissions', 'Manage organization', 'Manage users', 'Manage courses', 'Manage roles'],
level: 4
},
{
name: 'Maintainer',
icon: <Shield className="w-6 h-6 text-blue-600" />,
color: 'bg-blue-50 border-blue-200',
description: 'Mid-level manager with wide permissions',
permissions: ['Manage courses', 'Manage users', 'Manage assignments', ],
level: 3
},
{
name: 'Instructor',
icon: <GraduationCap className="w-6 h-6 text-green-600" />,
color: 'bg-green-50 border-green-200',
description: 'Can create courses but need ownership for content creation',
permissions: ['Create courses', 'Manage own courses', 'Create assignments', 'Grade assignments'],
level: 2
},
{
name: 'User',
icon: <User className="w-6 h-6 text-gray-600" />,
color: 'bg-gray-50 border-gray-200',
description: 'Read-Only Learner',
permissions: ['View courses', 'Submit assignments', 'Take assessments'],
level: 1
}
]
const courseOwnershipTypes = [
{
name: 'Creator',
icon: <Crown className="w-5 h-5 text-yellow-600" />,
color: 'bg-yellow-50 border-yellow-200',
description: 'Original course creator with full control',
permissions: ['Full course control', 'Manage contributors', 'Change access settings', 'Delete course']
},
{
name: 'Maintainer',
icon: <Shield className="w-5 h-5 text-blue-600" />,
color: 'bg-blue-50 border-blue-200',
description: 'Course maintainer with extensive permissions',
permissions: ['Manage course content', 'Manage contributors', 'Change access settings', 'Cannot delete course']
},
{
name: 'Contributor',
icon: <UserCog className="w-5 h-5 text-green-600" />,
color: 'bg-green-50 border-green-200',
description: 'Course contributor with limited permissions',
permissions: ['Edit course content', 'Create activities', 'Cannot manage contributors', 'Cannot change access']
}
]
return (
<div className="min-h-screen bg-[#f8f8f8] flex items-center justify-center p-6 pt-16 w-full">
<div className="w-full max-w-none mx-auto px-4 sm:px-6 lg:px-8">
{/* Top Icon */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-8"
>
<div className="inline-flex items-center justify-center w-16 h-16 bg-white rounded-full shadow-sm border border-gray-200 mb-6">
<Shield className="w-8 h-8 text-blue-500" />
</div>
</motion.div>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-center mb-12"
>
<Link
href={getUriWithOrg(org?.slug, '/dash')}
className="inline-flex items-center space-x-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="font-medium">Back to Dashboard</span>
</Link>
<div className="flex items-center justify-center space-x-3 mb-4">
<h1 className="text-4xl font-bold text-gray-900">Authorizations & Rights Guide</h1>
</div>
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
Understanding LearnHouse permissions, roles, and access controls based on RBAC system
</p>
</motion.div>
{/* Role Hierarchy Section */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="mb-16"
>
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center flex items-center justify-center space-x-2">
<Crown className="w-6 h-6 text-purple-600" />
<span>Role Hierarchy</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
{roleHierarchy.map((role, index) => (
<motion.div
key={role.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.1 }}
className={`bg-white rounded-xl border ${role.color} shadow-sm hover:shadow-lg transition-all duration-200 p-6 text-center`}
>
<div className="flex items-center justify-center space-x-3 mb-4">
{role.icon}
<h3 className="text-lg font-semibold text-gray-900">{role.name}</h3>
</div>
<p className="text-gray-600 text-sm mb-4">{role.description}</p>
<ul className="space-y-2 text-left">
{role.permissions.map((permission, permIndex) => (
<li key={permIndex} className="flex items-center space-x-2 text-sm text-gray-700">
<CheckCircle className="w-3 h-3 text-green-600 flex-shrink-0" />
<span>{permission}</span>
</li>
))}
</ul>
</motion.div>
))}
</div>
</motion.section>
{/* Course Ownership Types */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mb-16"
>
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center flex items-center justify-center space-x-2">
<Users className="w-6 h-6 text-blue-600" />
<span>Course Ownership Types</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto">
{courseOwnershipTypes.map((type, index) => (
<motion.div
key={type.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}
className={`bg-white rounded-xl border ${type.color} shadow-sm hover:shadow-lg transition-all duration-200 p-6 text-center`}
>
<div className="flex items-center justify-center space-x-3 mb-4">
{type.icon}
<h3 className="text-lg font-semibold text-gray-900">{type.name}</h3>
</div>
<p className="text-gray-600 text-sm mb-4">{type.description}</p>
<ul className="space-y-2 text-left">
{type.permissions.map((permission, permIndex) => (
<li key={permIndex} className="flex items-center space-x-2 text-sm text-gray-700">
<CheckCircle className="w-3 h-3 text-green-600 flex-shrink-0" />
<span>{permission}</span>
</li>
))}
</ul>
</motion.div>
))}
</div>
</motion.section>
</div>
</div>
)
}
export default RightsDocumentation

View file

@ -9,6 +9,7 @@ import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import Link from 'next/link'
import Image from 'next/image'
import EmptyThumbnailImage from '../../../public/empty_thumbnail.png'
import { BookOpen } from 'lucide-react'
export function CourseOverviewTop({
params,
@ -57,7 +58,14 @@ export function CourseOverviewTop({
</div>
</div>
</div>
<div className="flex items-center">
<div className="flex items-center space-x-4">
<Link
href={getUriWithOrg(org?.slug, '/dash/documentation/rights')}
className="rounded-lg bg-black hover:scale-105 transition-all duration-100 ease-linear antialiased p-2 px-5 font text-xs font-bold text-white drop-shadow-lg flex space-x-2 items-center"
>
<BookOpen className="w-4 h-4" />
<span>Rights Guide</span>
</Link>
<SaveState orgslug={params.orgslug} />
</div>
</div>

View file

@ -3,40 +3,193 @@ import { useLHSession } from '@components/Contexts/LHSessionContext';
import { useEffect, useState, useMemo } from 'react';
interface Role {
org: { id: number };
role: { id: number; role_uuid: string };
org: { id: number; org_uuid: string };
role: {
id: number;
role_uuid: string;
rights?: {
[key: string]: {
[key: string]: boolean;
};
};
};
}
function useAdminStatus() {
interface Rights {
courses: {
action_create: boolean;
action_read: boolean;
action_read_own: boolean;
action_update: boolean;
action_update_own: boolean;
action_delete: boolean;
action_delete_own: boolean;
};
users: {
action_create: boolean;
action_read: boolean;
action_update: boolean;
action_delete: boolean;
};
usergroups: {
action_create: boolean;
action_read: boolean;
action_update: boolean;
action_delete: boolean;
};
collections: {
action_create: boolean;
action_read: boolean;
action_update: boolean;
action_delete: boolean;
};
organizations: {
action_create: boolean;
action_read: boolean;
action_update: boolean;
action_delete: boolean;
};
coursechapters: {
action_create: boolean;
action_read: boolean;
action_update: boolean;
action_delete: boolean;
};
activities: {
action_create: boolean;
action_read: boolean;
action_update: boolean;
action_delete: boolean;
};
roles: {
action_create: boolean;
action_read: boolean;
action_update: boolean;
action_delete: boolean;
};
dashboard: {
action_access: boolean;
};
}
interface UseAdminStatusReturn {
isAdmin: boolean | null;
loading: boolean;
userRoles: Role[];
rights: Rights | null;
}
function useAdminStatus(): UseAdminStatusReturn {
const session = useLHSession() as any;
const org = useOrg() as any;
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [rights, setRights] = useState<Rights | null>(null);
const userRoles = useMemo(() => session?.data?.roles || [], [session?.data?.roles]);
useEffect(() => {
if (session.status === 'authenticated' && org?.id) {
const isAdminVar = userRoles.some((role: Role) => {
return (
role.org.id === org.id &&
(
role.role.id === 1 ||
role.role.id === 2 ||
role.role.role_uuid === 'role_global_admin' ||
role.role.role_uuid === 'role_global_maintainer'
)
);
// Extract rights from the backend session data
const extractRightsFromRoles = (): Rights | null => {
if (!userRoles || userRoles.length === 0) return null;
// Find roles for the current organization
const orgRoles = userRoles.filter((role: Role) => role.org.id === org.id);
if (orgRoles.length === 0) return null;
// Merge rights from all roles for this organization
const mergedRights: Rights = {
courses: {
action_create: false,
action_read: false,
action_read_own: false,
action_update: false,
action_update_own: false,
action_delete: false,
action_delete_own: false
},
users: {
action_create: false,
action_read: false,
action_update: false,
action_delete: false
},
usergroups: {
action_create: false,
action_read: false,
action_update: false,
action_delete: false
},
collections: {
action_create: false,
action_read: false,
action_update: false,
action_delete: false
},
organizations: {
action_create: false,
action_read: false,
action_update: false,
action_delete: false
},
coursechapters: {
action_create: false,
action_read: false,
action_update: false,
action_delete: false
},
activities: {
action_create: false,
action_read: false,
action_update: false,
action_delete: false
},
roles: {
action_create: false,
action_read: false,
action_update: false,
action_delete: false
},
dashboard: {
action_access: false
}
};
// Merge rights from all roles
orgRoles.forEach((role: Role) => {
if (role.role.rights) {
Object.keys(role.role.rights).forEach((resourceType) => {
if (mergedRights[resourceType as keyof Rights]) {
Object.keys(role.role.rights![resourceType]).forEach((action) => {
if (role.role.rights![resourceType][action] === true) {
(mergedRights[resourceType as keyof Rights] as any)[action] = true;
}
});
}
});
}
});
return mergedRights;
};
const extractedRights = extractRightsFromRoles();
setRights(extractedRights);
// User is admin only if they have dashboard access
const isAdminVar = extractedRights?.dashboard?.action_access === true;
setIsAdmin(isAdminVar);
setLoading(false); // Set loading to false once the status is determined
setLoading(false);
} else {
setIsAdmin(false);
setLoading(false); // Set loading to false if not authenticated or org not found
setRights(null);
setLoading(false);
}
}, [session.status, userRoles, org.id]);
return { isAdmin, loading };
return { isAdmin, loading, userRoles, rights };
}
export default useAdminStatus;

View file

@ -0,0 +1,64 @@
'use client'
import { getAPIUrl } from '@services/config/config'
import { swrFetcher } from '@services/utils/ts/requests'
import useSWR from 'swr'
import { useLHSession } from '@components/Contexts/LHSessionContext'
export interface CourseRights {
course_uuid: string
user_id: number
is_anonymous: boolean
permissions: {
read: boolean
create: boolean
update: boolean
delete: boolean
create_content: boolean
update_content: boolean
delete_content: boolean
manage_contributors: boolean
manage_access: boolean
grade_assignments: boolean
mark_activities_done: boolean
create_certifications: boolean
}
ownership: {
is_owner: boolean
is_creator: boolean
is_maintainer: boolean
is_contributor: boolean
authorship_status: string
}
roles: {
is_admin: boolean
is_maintainer_role: boolean
is_instructor: boolean
is_user: boolean
}
}
export function useCourseRights(courseuuid: string) {
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const { data: rights, error, isLoading } = useSWR<CourseRights>(
courseuuid ? `${getAPIUrl()}courses/${courseuuid}/rights` : null,
(url) => swrFetcher(url, access_token)
)
return {
rights,
error,
isLoading,
hasPermission: (permission: keyof CourseRights['permissions']) => {
return rights?.permissions?.[permission] ?? false
},
hasRole: (role: keyof CourseRights['roles']) => {
return rights?.roles?.[role] ?? false
},
isOwner: rights?.ownership?.is_owner ?? false,
isCreator: rights?.ownership?.is_creator ?? false,
isMaintainer: rights?.ownership?.is_maintainer ?? false,
isContributor: rights?.ownership?.is_contributor ?? false
}
}

View file

@ -1,23 +1,108 @@
'use client'
import React, { useEffect } from 'react'
import React, { useEffect, useMemo } from 'react'
import styled from 'styled-components'
import Link from 'next/link'
import { Package2, Settings } from 'lucide-react'
import { Package2, Settings, Crown, Shield, User, Users, Building, LogOut, User as UserIcon, Home, ChevronDown } from 'lucide-react'
import UserAvatar from '@components/Objects/UserAvatar'
import useAdminStatus from '@components/Hooks/useAdminStatus'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import { getUriWithoutOrg } from '@services/config/config'
import Tooltip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/ui/dropdown-menu"
import { signOut } from 'next-auth/react'
interface RoleInfo {
name: string;
icon: React.ReactNode;
bgColor: string;
textColor: string;
description: string;
}
export const HeaderProfileBox = () => {
const session = useLHSession() as any
const isUserAdmin = useAdminStatus()
const { isAdmin, loading, userRoles, rights } = useAdminStatus()
const org = useOrg() as any
useEffect(() => { }
, [session])
const userRoleInfo = useMemo((): RoleInfo | null => {
if (!userRoles || userRoles.length === 0) return null;
// Find the highest priority role for the current organization
const orgRoles = userRoles.filter((role: any) => role.org.id === org?.id);
if (orgRoles.length === 0) return null;
// Sort by role priority (admin > maintainer > instructor > user)
const sortedRoles = orgRoles.sort((a: any, b: any) => {
const getRolePriority = (role: any) => {
if (role.role.role_uuid === 'role_global_admin' || role.role.id === 1) return 4;
if (role.role.role_uuid === 'role_global_maintainer' || role.role.id === 2) return 3;
if (role.role.role_uuid === 'role_global_instructor' || role.role.id === 3) return 2;
return 1;
};
return getRolePriority(b) - getRolePriority(a);
});
const highestRole = sortedRoles[0];
// Define role configurations based on actual database roles
const roleConfigs: { [key: string]: RoleInfo } = {
'role_global_admin': {
name: 'ADMIN',
icon: <Crown size={12} />,
bgColor: 'bg-purple-600',
textColor: 'text-white',
description: 'Full platform control with all permissions'
},
'role_global_maintainer': {
name: 'MAINTAINER',
icon: <Shield size={12} />,
bgColor: 'bg-blue-600',
textColor: 'text-white',
description: 'Mid-level manager with wide permissions'
},
'role_global_instructor': {
name: 'INSTRUCTOR',
icon: <Users size={12} />,
bgColor: 'bg-green-600',
textColor: 'text-white',
description: 'Can manage their own content'
},
'role_global_user': {
name: 'USER',
icon: <User size={12} />,
bgColor: 'bg-gray-500',
textColor: 'text-white',
description: 'Read-Only Learner'
}
};
// Determine role based on role_uuid or id
let roleKey = 'role_global_user'; // default
if (highestRole.role.role_uuid) {
roleKey = highestRole.role.role_uuid;
} else if (highestRole.role.id === 1) {
roleKey = 'role_global_admin';
} else if (highestRole.role.id === 2) {
roleKey = 'role_global_maintainer';
} else if (highestRole.role.id === 3) {
roleKey = 'role_global_instructor';
}
return roleConfigs[roleKey] || roleConfigs['role_global_user'];
}, [userRoles, org?.id]);
return (
<ProfileArea>
{session.status == 'unauthenticated' && (
@ -35,35 +120,73 @@ export const HeaderProfileBox = () => {
)}
{session.status == 'authenticated' && (
<AccountArea className="space-x-0">
<div className="flex items-center space-x-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="cursor-pointer flex items-center space-x-3 hover:bg-gray-50 rounded-lg p-2 transition-colors">
<UserAvatar border="border-2" rounded="rounded-lg" width={30} />
<div className="flex flex-col space-y-0">
<div className="flex items-center space-x-2">
<div className='flex items-center space-x-2' >
<p className='text-sm capitalize'>{session.data.user.username}</p>
{isUserAdmin.isAdmin && <div className="text-[10px] bg-rose-300 px-2 font-bold rounded-md shadow-inner py-1">ADMIN</div>}
</div>
<div className="flex items-center space-x-2">
<p className='text-sm font-semibold text-gray-900 capitalize'>{session.data.user.username}</p>
{userRoleInfo && userRoleInfo.name !== 'USER' && (
<Tooltip
content={"Your Owned Courses"}
content={userRoleInfo.description}
sideOffset={15}
side="bottom"
>
<Link className="text-gray-600" href={'/dash/user-account/owned'}>
<Package2 size={14} />
</Link>
<div className={`text-[6px] ${userRoleInfo.bgColor} ${userRoleInfo.textColor} px-1 py-0.5 font-medium rounded-full flex items-center gap-0.5 w-fit`}>
{userRoleInfo.icon}
{userRoleInfo.name}
</div>
</Tooltip>
<Tooltip
content={"Your Settings"}
sideOffset={15}
side="bottom"
)}
</div>
<p className='text-xs text-gray-500'>{session.data.user.email}</p>
</div>
<ChevronDown size={16} className="text-gray-500" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>
<div className="flex items-center space-x-2">
<UserAvatar border="border-2" rounded="rounded-full" width={24} />
<div>
<p className="text-sm font-medium">{session.data.user.username}</p>
<p className="text-xs text-gray-500 capitalize">{session.data.user.email}</p>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{rights?.dashboard?.action_access && (
<DropdownMenuItem asChild>
<Link href="/dash" className="flex items-center space-x-2">
<Shield size={16} />
<span>Dashboard</span>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/dash/user-account/settings/general" className="flex items-center space-x-2">
<UserIcon size={16} />
<span>User Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/dash/user-account/owned" className="flex items-center space-x-2">
<Package2 size={16} />
<span>My Courses</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/' })}
className="flex items-center space-x-2 text-red-600 focus:text-red-600"
>
<Link className="text-gray-600" href={'/dash'}>
<Settings size={14} />
</Link>
</Tooltip>
</div>
<div className="py-4">
<UserAvatar border="border-4" rounded="rounded-lg" width={30} />
</div>
<LogOut size={16} />
<span>Sign Out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</AccountArea>
)}

View file

@ -166,6 +166,15 @@ export async function bulkRemoveContributors(course_uuid: string, data: any, acc
`${getAPIUrl()}courses/${course_uuid}/bulk-remove-contributors`,
RequestBodyWithAuthHeader('PUT', data, null, access_token || undefined)
)
const res = await getResponseMetadata(result)
const res = await errorHandling(result)
return res
}
export async function getCourseRights(course_uuid: string, access_token: string | null | undefined) {
const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/rights`,
RequestBodyWithAuthHeader('GET', null, null, access_token || undefined)
)
const res = await errorHandling(result)
return res
}