diff --git a/apps/api/src/db/activities.py b/apps/api/src/db/activities.py index 27c06bac..e5ae9a53 100644 --- a/apps/api/src/db/activities.py +++ b/apps/api/src/db/activities.py @@ -46,7 +46,6 @@ class Activity(ActivityBase, table=True): class ActivityCreate(ActivityBase): - order: int org_id: int = Field(default=None, foreign_key="organization.id") course_id: int = Field(default=None, foreign_key="course.id") chapter_id: int diff --git a/apps/api/src/db/collections.py b/apps/api/src/db/collections.py index 26085f2e..f3cbd850 100644 --- a/apps/api/src/db/collections.py +++ b/apps/api/src/db/collections.py @@ -17,7 +17,7 @@ class Collection(CollectionBase, table=True): class CollectionCreate(CollectionBase): - courses: list + courses: list[int] org_id: int = Field(default=None, foreign_key="organization.id") pass diff --git a/apps/api/src/db/courses.py b/apps/api/src/db/courses.py index 5774b28a..4d29251c 100644 --- a/apps/api/src/db/courses.py +++ b/apps/api/src/db/courses.py @@ -1,6 +1,6 @@ from typing import List, Optional from sqlmodel import Field, SQLModel - +from src.db.trails import TrailRead from src.db.chapters import ChapterRead @@ -39,6 +39,7 @@ class CourseUpdate(CourseBase): class CourseRead(CourseBase): id: int + org_id: int = Field(default=None, foreign_key="organization.id") course_uuid: str creation_date: str update_date: str @@ -53,3 +54,15 @@ class FullCourseRead(CourseBase): # Chapters, Activities chapters: List[ChapterRead] pass + + +class FullCourseReadWithTrail(CourseBase): + id: int + course_uuid: str + creation_date: str + update_date: str + # Chapters, Activities + chapters: List[ChapterRead] + # Trail + trail: TrailRead + pass diff --git a/apps/api/src/db/users.py b/apps/api/src/db/users.py index 8042ad3f..0c29799f 100644 --- a/apps/api/src/db/users.py +++ b/apps/api/src/db/users.py @@ -32,12 +32,14 @@ class UserUpdatePassword(SQLModel): class UserRead(UserBase): id: int + user_uuid: str class PublicUser(UserRead): pass class AnonymousUser(SQLModel): - id: str = "anonymous" + id: int = 0 + user_uuid: str = "user_anonymous" username: str = "anonymous" class User(UserBase, table=True): diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index f9bc33ea..335f3665 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -4,7 +4,6 @@ from src.core.events.database import get_db_session from src.db.users import PublicUser from src.db.courses import CourseCreate, CourseUpdate from src.security.auth import get_current_user - from src.services.courses.courses import ( create_course, get_course, @@ -46,9 +45,7 @@ async def api_create_course( learnings=learnings, tags=tags, ) - return await create_course( - request, course, current_user, db_session, thumbnail - ) + return await create_course(request, course, current_user, db_session, thumbnail) @router.put("/thumbnail/{course_id}") @@ -85,7 +82,7 @@ async def api_get_course( @router.get("/meta/{course_id}") async def api_get_course_meta( request: Request, - course_id: str, + course_id: int, db_session: Session = Depends(get_db_session), current_user: PublicUser = Depends(get_current_user), ): @@ -109,7 +106,9 @@ async def api_get_course_by_orgslug( """ Get houses by page and limit """ - return await get_courses_orgslug(request, current_user, page, limit, org_slug) + return await get_courses_orgslug( + request, current_user, org_slug, db_session, page, limit + ) @router.put("/") diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index 9f8c0aa1..6406dfc4 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -41,7 +41,7 @@ async def api_get_org( """ Get single Org by ID """ - return await get_organization(request, org_id, db_session) + return await get_organization(request, org_id, db_session, current_user) @router.get("/slug/{org_slug}") @@ -54,7 +54,7 @@ async def api_get_org_by_slug( """ Get single Org by Slug """ - return await get_organization_by_slug(request, org_slug, db_session) + return await get_organization_by_slug(request, org_slug, db_session, current_user) @router.put("/{org_id}/logo") @@ -109,7 +109,7 @@ async def api_update_org( @router.delete("/{org_id}") async def api_delete_org( request: Request, - org_id: str, + org_id: int, current_user: PublicUser = Depends(get_current_user), db_session: Session = Depends(get_db_session), ): diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index 82542c56..d82972da 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -1,9 +1,11 @@ from fastapi import APIRouter, Depends, Request from sqlmodel import Session +from src.security.rbac.rbac import authorization_verify_based_on_roles, authorization_verify_if_element_is_public, authorization_verify_if_user_is_author from src.security.auth import get_current_user from src.core.events.database import get_db_session from src.db.users import ( + PublicUser, User, UserCreate, UserRead, @@ -37,13 +39,14 @@ async def api_create_user_with_orgid( *, request: Request, db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), user_object: UserCreate, org_id: int, ) -> UserRead: """ Create User with Org ID """ - return await create_user(request, db_session, None, user_object, org_id) + return await create_user(request, db_session, current_user, user_object, org_id) @router.post("/", response_model=UserRead, tags=["users"]) @@ -51,12 +54,13 @@ async def api_create_user_without_org( *, request: Request, db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), user_object: UserCreate, ) -> UserRead: """ Create User """ - return await create_user_without_org(request, db_session, None, user_object) + return await create_user_without_org(request, db_session, current_user, user_object) @router.get("/user_id/{user_id}", response_model=UserRead, tags=["users"]) @@ -64,12 +68,13 @@ async def api_get_user_by_id( *, request: Request, db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), user_id: int, ) -> UserRead: """ Get User by ID """ - return await read_user_by_id(request, db_session, None, user_id) + return await read_user_by_id(request, db_session, current_user, user_id) @router.get("/user_uuid/{user_uuid}", response_model=UserRead, tags=["users"]) @@ -77,12 +82,13 @@ async def api_get_user_by_uuid( *, request: Request, db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), user_uuid: str, ) -> UserRead: """ Get User by UUID """ - return await read_user_by_uuid(request, db_session, None, user_uuid) + return await read_user_by_uuid(request, db_session, current_user, user_uuid) @router.put("/", response_model=UserRead, tags=["users"]) @@ -90,12 +96,13 @@ async def api_update_user( *, request: Request, db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), user_object: UserUpdate, ) -> UserRead: """ Update User """ - return await update_user(request, db_session, None, user_object) + return await update_user(request, db_session, current_user, user_object) @router.put("/change_password/", response_model=UserRead, tags=["users"]) @@ -103,12 +110,13 @@ async def api_update_user_password( *, request: Request, db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), form: UserUpdatePassword, ) -> UserRead: """ Update User Password """ - return await update_user_password(request, db_session, None, form) + return await update_user_password(request, db_session, current_user, form) @router.delete("/user_id/{user_id}", tags=["users"]) @@ -116,9 +124,10 @@ async def api_delete_user( *, request: Request, db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), user_id: int, ): """ Delete User """ - return await delete_user_by_id(request, db_session, None, user_id) + return await delete_user_by_id(request, db_session, current_user, user_id) diff --git a/apps/api/src/security/auth.py b/apps/api/src/security/auth.py index 3ddad99b..4d6d290a 100644 --- a/apps/api/src/security/auth.py +++ b/apps/api/src/security/auth.py @@ -1,6 +1,6 @@ from sqlmodel import Session from src.core.events.database import get_db_session -from src.db.users import AnonymousUser, User, UserRead +from src.db.users import AnonymousUser, PublicUser, User, UserRead from src.services.users.users import security_get_user from config.config import get_learnhouse_config from pydantic import BaseModel @@ -94,7 +94,7 @@ async def get_current_user( user = await security_get_user(request, db_session, email=token_data.username) # type: ignore # treated as an email if user is None: raise credentials_exception - return UserRead(**user.dict()) + return PublicUser(**user.dict()) else: return AnonymousUser() diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index 898ea594..0dba06d9 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -11,27 +11,21 @@ from src.db.user_organizations import UserOrganization from src.security.rbac.utils import check_element_type +# Tested and working async def authorization_verify_if_element_is_public( request, element_uuid: str, - user_id: str, action: Literal["read"], db_session: Session, ): element_nature = await check_element_type(element_uuid) - # Verifies if the element is public - if ( - element_nature == ("courses" or "collections") - and action == "read" - and user_id == "anonymous" - ): + if element_nature == ("courses" or "collections") and action == "read": if element_nature == "courses": statement = select(Course).where( Course.public == True, Course.course_uuid == element_uuid ) course = db_session.exec(statement).first() - if course: return True else: @@ -60,9 +54,10 @@ async def authorization_verify_if_element_is_public( ) +# Tested and working async def authorization_verify_if_user_is_author( request, - user_id: str, + user_id: int, action: Literal["read", "update", "delete", "create"], element_uuid: str, db_session: Session, @@ -74,26 +69,23 @@ async def authorization_verify_if_user_is_author( resource_author = db_session.exec(statement).first() if resource_author: - if resource_author.user_id == user_id: + if resource_author.user_id == int(user_id): if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or ( resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER ): return True + else: + return False else: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User rights (authorship) : You don't have the right to perform this action", - ) + return False else: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Wrong action (create)", - ) + return False +# Tested and working async def authorization_verify_based_on_roles( request: Request, - user_id: str, + user_id: int, action: Literal["read", "update", "delete", "create"], element_uuid: str, db_session: Session, @@ -104,8 +96,8 @@ async def authorization_verify_based_on_roles( statement = ( select(Role) .join(UserOrganization) + .where((UserOrganization.org_id == Role.org_id) | (Role.org_id == null())) .where(UserOrganization.user_id == user_id) - .where((UserOrganization.id == Role.org_id) | (UserOrganization.id == null)) ) user_roles_in_organization_and_standard_roles = db_session.exec(statement).all() @@ -120,15 +112,13 @@ async def authorization_verify_based_on_roles( else: return False else: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User rights (roles) : You don't have the right to perform this action", - ) + return False +# Tested and working async def authorization_verify_based_on_roles_and_authorship( request: Request, - user_id: str, + user_id: int, action: Literal["read", "update", "delete", "create"], element_uuid: str, db_session: Session, @@ -150,8 +140,8 @@ async def authorization_verify_based_on_roles_and_authorship( ) -async def authorization_verify_if_user_is_anon(user_id: str): - if user_id == "anonymous": +async def authorization_verify_if_user_is_anon(user_id: int): + if user_id == 0: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You should be logged in to perform this action", diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index 9531ca4c..d2355b2f 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -1,8 +1,13 @@ +from typing import Literal from sqlmodel import Session, select +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) from src.db.organizations import Organization from src.db.activities import ActivityCreate, Activity, ActivityRead, ActivityUpdate from src.db.chapter_activities import ChapterActivity -from src.db.users import PublicUser +from src.db.users import AnonymousUser, PublicUser from fastapi import HTTPException, Request from uuid import uuid4 from datetime import datetime @@ -16,7 +21,7 @@ from datetime import datetime async def create_activity( request: Request, activity_object: ActivityCreate, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): activity = Activity.from_orm(activity_object) @@ -31,6 +36,9 @@ async def create_activity( detail="Organization not found", ) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "create", db_session) + activity.activity_uuid = str(f"activity_{uuid4()}") activity.creation_date = str(datetime.now()) activity.update_date = str(datetime.now()) @@ -85,13 +93,16 @@ async def get_activity( detail="Activity not found", ) + # RBAC check + await rbac_check(request, activity.activity_uuid, current_user, "read", db_session) + return activity async def update_activity( request: Request, activity_object: ActivityUpdate, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): statement = select(Activity).where(Activity.id == activity_object.activity_id) @@ -103,6 +114,11 @@ async def update_activity( detail="Activity not found", ) + # RBAC check + await rbac_check( + request, activity.activity_uuid, current_user, "update", db_session + ) + del activity_object.activity_id # Update only the fields that were passed in @@ -120,7 +136,7 @@ async def update_activity( async def delete_activity( request: Request, activity_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): statement = select(Activity).where(Activity.id == activity_id) @@ -132,6 +148,11 @@ async def delete_activity( detail="Activity not found", ) + # RBAC check + await rbac_check( + request, activity.activity_uuid, current_user, "delete", db_session + ) + # Delete activity from chapter statement = select(ChapterActivity).where( ChapterActivity.activity_id == activity_id @@ -159,7 +180,7 @@ async def delete_activity( async def get_activities( request: Request, coursechapter_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): statement = select(ChapterActivity).where( @@ -173,4 +194,31 @@ async def get_activities( detail="No activities found", ) + # RBAC check + await rbac_check(request, "activity_x", current_user, "read", db_session) + return activities + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/activities/pdf.py b/apps/api/src/services/courses/activities/pdf.py index 5dae7d02..5d728709 100644 --- a/apps/api/src/services/courses/activities/pdf.py +++ b/apps/api/src/services/courses/activities/pdf.py @@ -1,4 +1,9 @@ +from typing import Literal from sqlmodel import Session, select +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) from src.db.chapters import Chapter from src.db.activities import ( Activity, @@ -8,7 +13,7 @@ from src.db.activities import ( ) from src.db.chapter_activities import ChapterActivity from src.db.course_chapters import CourseChapter -from src.db.users import PublicUser +from src.db.users import AnonymousUser, PublicUser from src.services.courses.activities.uploads.pdfs import upload_pdf from fastapi import HTTPException, status, UploadFile, Request from uuid import uuid4 @@ -19,10 +24,13 @@ async def create_documentpdf_activity( request: Request, name: str, chapter_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, pdf_file: UploadFile | None = None, ): + # 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() @@ -94,7 +102,7 @@ async def create_documentpdf_activity( # Add activity to chapter activity_chapter = ChapterActivity( chapter_id=(int(chapter_id)), - activity_id=activity.id is not None, + activity_id=activity.id, # type: ignore course_id=coursechapter.course_id, org_id=coursechapter.org_id, creation_date=str(datetime.now()), @@ -113,3 +121,27 @@ async def create_documentpdf_activity( db_session.refresh(activity_chapter) return ActivityRead.from_orm(activity) + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index 70babb80..a1d3beda 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -2,11 +2,20 @@ from typing import Literal from pydantic import BaseModel from sqlmodel import Session, select +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) from src.db.chapters import Chapter -from src.db.activities import Activity, ActivityRead, ActivitySubTypeEnum, ActivityTypeEnum +from src.db.activities import ( + Activity, + ActivityRead, + ActivitySubTypeEnum, + ActivityTypeEnum, +) from src.db.chapter_activities import ChapterActivity from src.db.course_chapters import CourseChapter -from src.db.users import PublicUser +from src.db.users import AnonymousUser, PublicUser from src.services.courses.activities.uploads.videos import upload_video from fastapi import HTTPException, status, UploadFile, Request from uuid import uuid4 @@ -21,6 +30,9 @@ async def create_video_activity( db_session: Session, video_file: UploadFile | None = None, ): + # 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() @@ -95,8 +107,8 @@ async def create_video_activity( # update chapter chapter_activity_object = ChapterActivity( - chapter_id=coursechapter.id is not None, - activity_id=activity.id is not None, + chapter_id=chapter.id, # type: ignore + activity_id=activity.id, # type: ignore course_id=coursechapter.course_id, org_id=coursechapter.org_id, creation_date=str(datetime.now()), @@ -111,6 +123,7 @@ async def create_video_activity( return ActivityRead.from_orm(activity) + class ExternalVideo(BaseModel): name: str uri: str @@ -124,10 +137,13 @@ class ExternalVideoInDB(BaseModel): async def create_external_video_activity( request: Request, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, 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() @@ -174,8 +190,8 @@ async def create_external_video_activity( # update chapter chapter_activity_object = ChapterActivity( - chapter_id=coursechapter.id is not None, - activity_id=activity.id is not None, + chapter_id=coursechapter.id, # type: ignore + activity_id=activity.id, # type: ignore creation_date=str(datetime.now()), update_date=str(datetime.now()), order=1, @@ -186,3 +202,24 @@ async def create_external_video_activity( db_session.commit() return ActivityRead.from_orm(activity) + + +async def rbac_check( + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index f8ad98d4..1fa3aae9 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -1,7 +1,12 @@ from datetime import datetime -from typing import List +from typing import List, Literal from uuid import uuid4 from sqlmodel import Session, select +from src.db.users import AnonymousUser +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) from src.db.course_chapters import CourseChapter from src.db.activities import Activity, ActivityRead from src.db.chapter_activities import ChapterActivity @@ -26,11 +31,14 @@ from fastapi import HTTPException, status, Request async def create_chapter( request: Request, chapter_object: ChapterCreate, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ) -> ChapterRead: chapter = Chapter.from_orm(chapter_object) + # RBAC check + await rbac_check(request, "chapter_x", current_user, "create", db_session) + # complete chapter object chapter.course_id = chapter_object.course_id chapter.chapter_uuid = f"chapter_{uuid4()}" @@ -87,7 +95,7 @@ async def create_chapter( async def get_chapter( request: Request, chapter_id: int, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ) -> ChapterRead: statement = select(Chapter).where(Chapter.id == chapter_id) @@ -98,6 +106,9 @@ async def get_chapter( status_code=status.HTTP_409_CONFLICT, detail="Chapter does not exist" ) + # RBAC check + await rbac_check(request, chapter.chapter_uuid, current_user, "read", db_session) + # Get activities for this chapter statement = ( select(Activity) @@ -119,7 +130,7 @@ async def get_chapter( async def update_chapter( request: Request, chapter_object: ChapterUpdate, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ) -> ChapterRead: statement = select(Chapter).where(Chapter.id == chapter_object.chapter_id) @@ -130,6 +141,9 @@ async def update_chapter( status_code=status.HTTP_409_CONFLICT, detail="Chapter does not exist" ) + # RBAC check + await rbac_check(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(): if value is not None: @@ -148,7 +162,7 @@ async def update_chapter( async def delete_chapter( request: Request, chapter_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): statement = select(Chapter).where(Chapter.id == chapter_id) @@ -159,6 +173,9 @@ async def delete_chapter( status_code=status.HTTP_409_CONFLICT, detail="Chapter does not exist" ) + # RBAC check + await rbac_check(request, chapter.chapter_uuid, current_user, "delete", db_session) + db_session.delete(chapter) db_session.commit() @@ -173,15 +190,12 @@ async def delete_chapter( return {"detail": "chapter deleted"} -#################################################### -# Misc -#################################################### - async def get_course_chapters( request: Request, course_id: int, db_session: Session, + current_user: PublicUser | AnonymousUser, page: int = 1, limit: int = 10, ) -> List[ChapterRead]: @@ -195,6 +209,9 @@ async def get_course_chapters( chapters = [ChapterRead(**chapter.dict(), activities=[]) for chapter in chapters] + # RBAC check + await rbac_check(request, "chapter_x", current_user, "read", db_session) + # Get activities for each chapter for chapter in chapters: statement = ( @@ -233,6 +250,9 @@ async def get_depreceated_course_chapters( status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" ) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + # Get chapters that are linked to his course and order them by order, using the order field in the CourseChapter table statement = ( select(Chapter) @@ -310,6 +330,9 @@ async def reorder_chapters_and_activities( status_code=status.HTTP_409_CONFLICT, detail="Course does not exist" ) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + ########### # Chapters ########### @@ -469,3 +492,27 @@ async def reorder_chapters_and_activities( db_session.commit() return {"detail": "Chapters reordered"} + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/collections.py b/apps/api/src/services/courses/collections.py index 73e911b0..73855956 100644 --- a/apps/api/src/services/courses/collections.py +++ b/apps/api/src/services/courses/collections.py @@ -1,7 +1,12 @@ from datetime import datetime -from typing import List +from typing import List, Literal from uuid import uuid4 from sqlmodel import Session, select +from src.db.users import AnonymousUser +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) from src.db.collections import ( Collection, CollectionCreate, @@ -37,6 +42,11 @@ async def get_collection( status_code=status.HTTP_409_CONFLICT, detail="Collection does not exist" ) + # RBAC check + await rbac_check( + request, collection.collection_uuid, current_user, "read", db_session + ) + # get courses in collection statement = ( select(Course) @@ -58,6 +68,9 @@ async def create_collection( ) -> CollectionRead: collection = Collection.from_orm(collection_object) + # RBAC check + await rbac_check(request, "collection_x", current_user, "create", db_session) + # Complete the collection object collection.collection_uuid = f"collection_{uuid4()}" collection.creation_date = str(datetime.now()) @@ -70,16 +83,17 @@ async def create_collection( db_session.refresh(collection) # Link courses to collection - for course in collection_object.courses: - collection_course = CollectionCourse( - collection_id=int(collection.id is not None), - course_id=int(course), - org_id=int(collection_object.org_id), - creation_date=str(datetime.now()), - update_date=str(datetime.now()), - ) - # Add collection_course to database - db_session.add(collection_course) + if collection: + for course_id in collection_object.courses: + collection_course = CollectionCourse( + collection_id=int(collection.id), # type: ignore + course_id=course_id, + org_id=int(collection_object.org_id), + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + # Add collection_course to database + db_session.add(collection_course) db_session.commit() db_session.refresh(collection) @@ -113,6 +127,11 @@ async def update_collection( status_code=status.HTTP_409_CONFLICT, detail="Collection does not exist" ) + # RBAC check + await rbac_check( + request, collection.collection_uuid, current_user, "update", db_session + ) + courses = collection_object.courses del collection_object.collection_id @@ -142,7 +161,7 @@ async def update_collection( # Add new collection_courses for course in courses or []: collection_course = CollectionCourse( - collection_id=int(collection.id is not None), + collection_id=int(collection.id), # type: ignore course_id=int(course), org_id=int(collection.org_id), creation_date=str(datetime.now()), @@ -180,6 +199,11 @@ async def delete_collection( detail="Collection not found", ) + # RBAC check + await rbac_check( + request, collection.collection_uuid, current_user, "delete", db_session + ) + # delete collection from database db_session.delete(collection) db_session.commit() @@ -195,11 +219,14 @@ async def delete_collection( async def get_collections( request: Request, org_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, page: int = 1, limit: int = 10, ) -> List[CollectionRead]: + # RBAC check + await rbac_check(request, "collection_x", current_user, "read", db_session) + statement = ( select(Collection).where(Collection.org_id == org_id).distinct(Collection.id) ) @@ -223,3 +250,27 @@ async def get_collections( collections_with_courses.append(collection) return collections_with_courses + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, + current_user.id, + action, + course_id, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index afe4b614..4da62ba5 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -1,12 +1,28 @@ +from calendar import c import json +from queue import Full import resource from typing import Literal from uuid import uuid4 from sqlmodel import Session, select +from src.db import chapters +from src.db.activities import Activity, ActivityRead +from src.db.chapter_activities import ChapterActivity +from src.db.chapters import Chapter, ChapterRead +from src.db.organizations import Organization +from src.db.trails import TrailRead + +from src.services.trail.trail import get_user_trail_with_orgid from src import db from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum from src.db.users import PublicUser, AnonymousUser -from src.db.courses import Course, CourseCreate, CourseRead, CourseUpdate +from src.db.courses import ( + Course, + CourseCreate, + CourseRead, + CourseUpdate, + FullCourseReadWithTrail, +) from src.security.rbac.rbac import ( authorization_verify_based_on_roles_and_authorship, authorization_verify_if_element_is_public, @@ -18,7 +34,10 @@ from datetime import datetime async def get_course( - request: Request, course_id: str, current_user: PublicUser, db_session: Session + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, ): statement = select(Course).where(Course.id == course_id) course = db_session.exec(statement).first() @@ -29,12 +48,21 @@ async def get_course( detail="Course not found", ) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + return course async def get_course_meta( - request: Request, course_id: str, current_user: PublicUser, db_session: Session -): + request: Request, + course_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> FullCourseReadWithTrail: + # Avoid circular import + from src.services.courses.chapters import get_course_chapters + course_statement = select(Course).where(Course.id == course_id) course = db_session.exec(course_statement).first() @@ -44,22 +72,40 @@ async def get_course_meta( detail="Course not found", ) - # todo : get course chapters - # todo : get course activities - # todo : get trail + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "read", db_session) - return course + course = CourseRead.from_orm(course) + + # Get course chapters + chapters = await get_course_chapters(request, course.id, db_session, current_user) + + # Trail + trail = await get_user_trail_with_orgid( + request, current_user, course.org_id, db_session + ) + + trail = TrailRead.from_orm(trail) + + return FullCourseReadWithTrail( + **course.dict(), + chapters=chapters, + trail=trail, + ) async def create_course( request: Request, course_object: CourseCreate, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, thumbnail_file: UploadFile | None = None, ): course = Course.from_orm(course_object) + # RBAC check + await rbac_check(request, "course_x", current_user, "create", db_session) + # Complete course object course.org_id = course.org_id course.course_uuid = str(f"course_{uuid4()}") @@ -69,7 +115,9 @@ async def create_course( # Upload thumbnail if thumbnail_file and thumbnail_file.filename: name_in_disk = f"{course.course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" - await upload_thumbnail(thumbnail_file, name_in_disk, course_object.org_id, course.course_uuid) + await upload_thumbnail( + thumbnail_file, name_in_disk, course_object.org_id, course.course_uuid + ) course_object.thumbnail = name_in_disk # Insert course @@ -97,7 +145,7 @@ async def create_course( async def update_course_thumbnail( request: Request, course_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, thumbnail_file: UploadFile | None = None, ): @@ -112,6 +160,9 @@ async def update_course_thumbnail( detail="Course not found", ) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + # Upload thumbnail if thumbnail_file and thumbnail_file.filename: name_in_disk = ( @@ -143,7 +194,7 @@ async def update_course_thumbnail( async def update_course( request: Request, course_object: CourseUpdate, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): statement = select(Course).where(Course.id == course_object.course_id) @@ -154,7 +205,10 @@ async def update_course( status_code=404, detail="Course not found", ) - + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + del course_object.course_id # Update only the fields that were passed in @@ -173,7 +227,10 @@ async def update_course( async def delete_course( - request: Request, course_id: str, current_user: PublicUser, db_session: Session + request: Request, + course_id: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, ): statement = select(Course).where(Course.id == course_id) course = db_session.exec(statement).first() @@ -184,92 +241,74 @@ async def delete_course( detail="Course not found", ) + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "delete", db_session) + db_session.delete(course) db_session.commit() return {"detail": "Course deleted"} -#################################################### -# Misc -#################################################### - - async def get_courses_orgslug( request: Request, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, + org_slug: str, + db_session: Session, page: int = 1, limit: int = 10, - org_slug: str | None = None, ): - courses = request.app.db["courses"] - orgs = request.app.db["organizations"] + statement_public = ( + select(Course) + .join(Organization) + .where(Organization.slug == org_slug, Course.public == True) + ) + statement_all = ( + select(Course).join(Organization).where(Organization.slug == org_slug) + ) - # get org_id from slug - org = await orgs.find_one({"slug": org_slug}) - - if not org: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, detail="Organization does not exist" - ) - - # show only public courses if user is not logged in - if current_user.id == "anonymous": - all_courses = ( - courses.find({"org_id": org["org_id"], "public": True}) - .sort("name", 1) - .skip(10 * (page - 1)) - .limit(limit) - ) + if current_user.id == 0: + statement = statement_public else: - all_courses = ( - courses.find({"org_id": org["org_id"]}) - .sort("name", 1) - .skip(10 * (page - 1)) - .limit(limit) - ) + # RBAC check + await authorization_verify_if_user_is_anon(current_user.id) - return [ - json.loads(json.dumps(course, default=str)) - for course in await all_courses.to_list(length=100) - ] + statement = statement_all + + courses = db_session.exec(statement) + + return courses -#### Security #################################################### +## 🔒 RBAC Utils ## -async def verify_rights( +async def rbac_check( request: Request, - course_id: str, + course_uuid: str, current_user: PublicUser | AnonymousUser, action: Literal["create", "read", "update", "delete"], db_session: Session, ): if action == "read": - if current_user.id == "anonymous": + if current_user.id == 0: # Anonymous user await authorization_verify_if_element_is_public( - request, course_id, str(current_user.id), action, db_session + request, course_uuid, action, db_session ) else: - await authorization_verify_based_on_roles_and_authorship( - request, - str(current_user.id), - action, - course_id, - db_session, + request, current_user.id, action, course_uuid, db_session ) else: - - await authorization_verify_if_user_is_anon(str(current_user.id)) + await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_based_on_roles_and_authorship( request, - str(current_user.id), + current_user.id, action, - course_id, + course_uuid, db_session, ) -#### Security #################################################### +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index 40cfdccc..6db9c550 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -1,7 +1,12 @@ from datetime import datetime +from typing import Literal from uuid import uuid4 from sqlmodel import Session, select -from src.db.users import PublicUser +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) +from src.db.users import AnonymousUser, PublicUser from src.db.user_organizations import UserOrganization from src.db.organizations import ( Organization, @@ -13,7 +18,12 @@ from src.services.orgs.logos import upload_org_logo from fastapi import HTTPException, UploadFile, status, Request -async def get_organization(request: Request, org_id: str, db_session: Session): +async def get_organization( + request: Request, + org_id: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, +): statement = select(Organization).where(Organization.id == org_id) result = db_session.exec(statement) @@ -25,11 +35,17 @@ async def get_organization(request: Request, org_id: str, db_session: Session): detail="Organization not found", ) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + return org async def get_organization_by_slug( - request: Request, org_slug: str, db_session: Session + request: Request, + org_slug: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, ): statement = select(Organization).where(Organization.slug == org_slug) result = db_session.exec(statement) @@ -42,13 +58,16 @@ async def get_organization_by_slug( detail="Organization not found", ) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + return org async def create_org( request: Request, org_object: OrganizationCreate, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): statement = select(Organization).where(Organization.slug == org_object.slug) @@ -64,6 +83,9 @@ async def create_org( org = Organization.from_orm(org_object) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "create", db_session) + # Complete the org object org.org_uuid = f"org_{uuid4()}" org.creation_date = str(datetime.now()) @@ -92,7 +114,7 @@ async def create_org( async def update_org( request: Request, org_object: OrganizationUpdate, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): statement = select(Organization).where(Organization.id == org_object.org_id) @@ -106,6 +128,9 @@ async def update_org( detail="Organization slug not found", ) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + org = Organization.from_orm(org_object) # Verify if the new slug is already in use @@ -142,7 +167,7 @@ async def update_org_logo( request: Request, logo_file: UploadFile, org_id: str, - current_user: PublicUser, + current_user: PublicUser | AnonymousUser, db_session: Session, ): statement = select(Organization).where(Organization.id == org_id) @@ -156,6 +181,9 @@ async def update_org_logo( detail="Organization not found", ) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + # Upload logo name_in_disk = await upload_org_logo(logo_file, org_id) @@ -173,7 +201,10 @@ async def update_org_logo( async def delete_org( - request: Request, org_id: str, current_user: PublicUser, db_session: Session + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, ): statement = select(Organization).where(Organization.id == org_id) result = db_session.exec(statement) @@ -186,6 +217,9 @@ async def delete_org( detail="Organization not found", ) + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "delete", db_session) + db_session.delete(org) db_session.commit() @@ -224,3 +258,28 @@ async def get_orgs_by_user( orgs = result.all() return orgs + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + org_id: str, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + db_session: Session, +): + # Organizations are readable by anyone + if action == "read": + return True + + else: + await authorization_verify_if_user_is_anon(current_user.id) + + await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, action, org_id, db_session + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/roles/roles.py b/apps/api/src/services/roles/roles.py index d7ac88e8..982d76f8 100644 --- a/apps/api/src/services/roles/roles.py +++ b/apps/api/src/services/roles/roles.py @@ -1,6 +1,12 @@ +from typing import Literal from uuid import uuid4 from sqlmodel import Session, select -from src.db.users import PublicUser +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, + authorization_verify_if_user_is_author, +) +from src.db.users import AnonymousUser, PublicUser from src.db.roles import Role, RoleCreate, RoleUpdate from fastapi import HTTPException, Request from datetime import datetime @@ -14,6 +20,9 @@ async def create_role( ): role = Role.from_orm(role_object) + # RBAC check + await rbac_check(request, current_user, "create", "role_xxx", db_session) + # Complete the role object role.role_uuid = f"role_{uuid4()}" role.creation_date = str(datetime.now()) @@ -40,6 +49,9 @@ async def read_role( detail="Role not found", ) + # RBAC check + await rbac_check(request, current_user, "read", role.role_uuid, db_session) + return role @@ -60,6 +72,9 @@ async def update_role( detail="Role not found", ) + # RBAC check + await rbac_check(request, current_user, "update", role.role_uuid, db_session) + # Complete the role object role.update_date = str(datetime.now()) @@ -81,6 +96,9 @@ async def update_role( async def delete_role( request: Request, db_session: Session, role_id: str, current_user: PublicUser ): + # RBAC check + await rbac_check(request, current_user, "delete", role_id, db_session) + statement = select(Role).where(Role.id == role_id) result = db_session.exec(statement) @@ -96,3 +114,23 @@ async def delete_role( db_session.commit() return "Role deleted" + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + role_uuid: str, + 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, role_uuid, db_session + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/trail/trail.py b/apps/api/src/services/trail/trail.py index 7c4d5b58..c79d4d0d 100644 --- a/apps/api/src/services/trail/trail.py +++ b/apps/api/src/services/trail/trail.py @@ -6,7 +6,7 @@ from src.db.courses import Course from src.db.trail_runs import TrailRun, TrailRunRead from src.db.trail_steps import TrailStep from src.db.trails import Trail, TrailCreate, TrailRead -from src.db.users import PublicUser +from src.db.users import AnonymousUser, PublicUser async def create_user_trail( @@ -80,7 +80,7 @@ async def get_user_trails( async def get_user_trail_with_orgid( - request: Request, user: PublicUser, org_id: int, db_session: Session + request: Request, user: PublicUser | AnonymousUser, org_id: int, db_session: Session ) -> TrailRead: statement = select(Trail).where(Trail.org_id == org_id, Trail.user_id == user.id) trail = db_session.exec(statement).first() diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 29979fab..94fc594b 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -1,9 +1,17 @@ from datetime import datetime +from typing import Literal from uuid import uuid4 from fastapi import HTTPException, Request, status from sqlmodel import Session, select +from src import db +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles, + authorization_verify_based_on_roles_and_authorship, + authorization_verify_if_user_is_anon, +) from src.db.organizations import Organization from src.db.users import ( + AnonymousUser, PublicUser, User, UserCreate, @@ -18,12 +26,15 @@ from src.security.security import security_hash_password, security_verify_passwo async def create_user( request: Request, db_session: Session, - current_user: PublicUser | None, + current_user: PublicUser | AnonymousUser, user_object: UserCreate, org_id: int, ): user = User.from_orm(user_object) + # RBAC check + await rbac_check(request, current_user, "create", "user_x", db_session) + # Complete the user object user.user_uuid = f"user_{uuid4()}" user.password = await security_hash_password(user_object.password) @@ -94,11 +105,14 @@ async def create_user( async def create_user_without_org( request: Request, db_session: Session, - current_user: PublicUser | None, + current_user: PublicUser | AnonymousUser, user_object: UserCreate, ): user = User.from_orm(user_object) + # RBAC check + await rbac_check(request, current_user, "create", "user_x", db_session) + # Complete the user object user.user_uuid = f"user_{uuid4()}" user.password = await security_hash_password(user_object.password) @@ -146,7 +160,7 @@ async def create_user_without_org( async def update_user( request: Request, db_session: Session, - current_user: PublicUser | None, + current_user: PublicUser | AnonymousUser, user_object: UserUpdate, ): # Get user @@ -158,6 +172,9 @@ async def update_user( status_code=400, detail="User does not exist", ) + + # RBAC check + await rbac_check(request, current_user, "update", user.user_uuid, db_session) # Update user user_data = user_object.dict(exclude_unset=True) @@ -179,7 +196,7 @@ async def update_user( async def update_user_password( request: Request, db_session: Session, - current_user: PublicUser | None, + current_user: PublicUser | AnonymousUser, form: UserUpdatePassword, ): # Get user @@ -191,6 +208,9 @@ async def update_user_password( status_code=400, detail="User does not exist", ) + + # RBAC check + await rbac_check(request, current_user, "update", user.user_uuid, db_session) if not await security_verify_password(form.old_password, user.password): raise HTTPException( @@ -214,7 +234,7 @@ async def update_user_password( async def read_user_by_id( request: Request, db_session: Session, - current_user: PublicUser | None, + current_user: PublicUser | AnonymousUser, user_id: int, ): # Get user @@ -227,6 +247,9 @@ async def read_user_by_id( detail="User does not exist", ) + # RBAC check + await rbac_check(request, current_user, "read", user.user_uuid, db_session) + user = UserRead.from_orm(user) return user @@ -235,11 +258,11 @@ async def read_user_by_id( async def read_user_by_uuid( request: Request, db_session: Session, - current_user: PublicUser | None, - uuid: str, + current_user: PublicUser | AnonymousUser, + user_uuid: str, ): # Get user - statement = select(User).where(User.user_uuid == uuid) + statement = select(User).where(User.user_uuid == user_uuid) user = db_session.exec(statement).first() if not user: @@ -248,6 +271,9 @@ async def read_user_by_uuid( detail="User does not exist", ) + # RBAC check + await rbac_check(request, current_user, "read", user.user_uuid, db_session) + user = UserRead.from_orm(user) return user @@ -256,7 +282,7 @@ async def read_user_by_uuid( async def delete_user_by_id( request: Request, db_session: Session, - current_user: PublicUser | None, + current_user: PublicUser | AnonymousUser, user_id: int, ): # Get user @@ -269,6 +295,9 @@ async def delete_user_by_id( detail="User does not exist", ) + # RBAC check + await rbac_check(request, current_user, "delete", user.user_uuid, db_session) + # Delete user db_session.delete(user) db_session.commit() @@ -293,3 +322,37 @@ async def security_get_user(request: Request, db_session: Session, email: str) - user = User(**user.dict()) return user + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + current_user: PublicUser | AnonymousUser, + action: Literal["create", "read", "update", "delete"], + user_uuid: str, + db_session: Session, +): + if action == "create": + if current_user.id == 0: # if user is anonymous + return True + else: + res = await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, "create", "user_x", db_session + ) + + + else: + await authorization_verify_if_user_is_anon(current_user.id) + + # if user is the same as the one being read + if current_user.user_uuid == user_uuid: + return True + + await authorization_verify_based_on_roles_and_authorship( + request, current_user.id, "read", action, db_session + ) + + +## 🔒 RBAC Utils ##