diff --git a/apps/api/src/db/roles.py b/apps/api/src/db/roles.py index 8ea66dbb..7d1c8a0b 100644 --- a/apps/api/src/db/roles.py +++ b/apps/api/src/db/roles.py @@ -19,6 +19,7 @@ class Permission(BaseModel): class Rights(BaseModel): courses: Permission users: Permission + usergroups : Permission collections: Permission organizations: Permission coursechapters: Permission diff --git a/apps/api/src/db/usergroup_ressources.py b/apps/api/src/db/usergroup_resources.py similarity index 85% rename from apps/api/src/db/usergroup_ressources.py rename to apps/api/src/db/usergroup_resources.py index f8796ca5..f9069b29 100644 --- a/apps/api/src/db/usergroup_ressources.py +++ b/apps/api/src/db/usergroup_resources.py @@ -3,12 +3,12 @@ from sqlalchemy import Column, ForeignKey, Integer from sqlmodel import Field, SQLModel -class UserGroupRessource(SQLModel, table=True): +class UserGroupResource(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) usergroup_id: int = Field( sa_column=Column(Integer, ForeignKey("usergroup.id", ondelete="CASCADE")) ) - ressource_uuid: str = "" + resource_uuid: str = "" org_id: int = Field( sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE")) ) diff --git a/apps/api/src/routers/usergroups.py b/apps/api/src/routers/usergroups.py index 5120ec7e..7f8351c6 100644 --- a/apps/api/src/routers/usergroups.py +++ b/apps/api/src/routers/usergroups.py @@ -5,12 +5,13 @@ from src.services.users.users import delete_user_by_id from src.db.usergroups import UserGroupCreate, UserGroupRead, UserGroupUpdate from src.db.users import PublicUser from src.services.users.usergroups import ( - add_ressources_to_usergroup, + add_resources_to_usergroup, add_users_to_usergroup, create_usergroup, delete_usergroup_by_id, read_usergroup_by_id, - remove_ressources_from_usergroup, + read_usergroups_by_org_id, + remove_resources_from_usergroup, remove_users_from_usergroup, update_usergroup_by_id, ) @@ -50,6 +51,20 @@ async def api_get_usergroup( return await read_usergroup_by_id(request, db_session, current_user, usergroup_id) +@router.get("/org/{org_id}", response_model=list[UserGroupRead], tags=["usergroups"]) +async def api_get_usergroups( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + org_id: int, +) -> list[UserGroupRead]: + """ + Get UserGroups by Org + """ + return await read_usergroups_by_org_id(request, db_session, current_user, org_id) + + @router.put("/{usergroup_id}", response_model=UserGroupRead, tags=["usergroups"]) async def api_update_usergroup( *, @@ -115,35 +130,35 @@ async def api_delete_users_from_usergroup( ) -@router.post("/{usergroup_id}/add_ressources", tags=["usergroups"]) -async def api_add_ressources_to_usergroup( +@router.post("/{usergroup_id}/add_resources", tags=["usergroups"]) +async def api_add_resources_to_usergroup( *, request: Request, db_session: Session = Depends(get_db_session), current_user: PublicUser = Depends(get_current_user), usergroup_id: int, - ressource_uuids: str, + resource_uuids: str, ) -> str: """ - Add Ressources to UserGroup + Add Resources to UserGroup """ - return await add_ressources_to_usergroup( - request, db_session, current_user, usergroup_id, ressource_uuids + return await add_resources_to_usergroup( + request, db_session, current_user, usergroup_id, resource_uuids ) -@router.delete("/{usergroup_id}/remove_ressources", tags=["usergroups"]) -async def api_delete_ressources_from_usergroup( +@router.delete("/{usergroup_id}/remove_resources", tags=["usergroups"]) +async def api_delete_resources_from_usergroup( *, request: Request, db_session: Session = Depends(get_db_session), current_user: PublicUser = Depends(get_current_user), usergroup_id: int, - ressource_uuids: str, + resource_uuids: str, ) -> str: """ - Delete Ressources from UserGroup + Delete Resources from UserGroup """ - return await remove_ressources_from_usergroup( - request, db_session, current_user, usergroup_id, ressource_uuids + return await remove_resources_from_usergroup( + request, db_session, current_user, usergroup_id, resource_uuids ) diff --git a/apps/api/src/security/rbac/utils.py b/apps/api/src/security/rbac/utils.py index 0b2d707d..aa6560cd 100644 --- a/apps/api/src/security/rbac/utils.py +++ b/apps/api/src/security/rbac/utils.py @@ -1,25 +1,27 @@ from fastapi import HTTPException, status -async def check_element_type(element_id): +async def check_element_type(element_uuid): """ Check if the element is a course, a user, a house or a collection, by checking its prefix """ - if element_id.startswith("course_"): + if element_uuid.startswith("course_"): return "courses" - elif element_id.startswith("user_"): + elif element_uuid.startswith("user_"): return "users" - elif element_id.startswith("house_"): + elif element_uuid.startswith("usergroup_"): + return "usergroups" + elif element_uuid.startswith("house_"): return "houses" - elif element_id.startswith("org_"): + elif element_uuid.startswith("org_"): return "organizations" - elif element_id.startswith("chapter_"): + elif element_uuid.startswith("chapter_"): return "coursechapters" - elif element_id.startswith("collection_"): + elif element_uuid.startswith("collection_"): return "collections" - elif element_id.startswith("activity_"): + elif element_uuid.startswith("activity_"): return "activities" - elif element_id.startswith("role_"): + elif element_uuid.startswith("role_"): return "roles" else: raise HTTPException( @@ -28,8 +30,8 @@ async def check_element_type(element_id): ) -async def get_singular_form_of_element(element_id): - element_type = await check_element_type(element_id) +async def get_singular_form_of_element(element_uuid): + element_type = await check_element_type(element_uuid) if element_type == "activities": return "activity" @@ -38,8 +40,8 @@ async def get_singular_form_of_element(element_id): return singular_form_element -async def get_id_identifier_of_element(element_id): - singular_form_element = await get_singular_form_of_element(element_id) +async def get_id_identifier_of_element(element_uuid): + singular_form_element = await get_singular_form_of_element(element_uuid) if singular_form_element == "ogranizations": return "org_id" diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index b2872616..31b3d7ef 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -559,7 +559,6 @@ async def rbac_check( res = await authorization_verify_if_element_is_public( request, course_uuid, action, db_session ) - print("res", res) return res else: res = await authorization_verify_based_on_roles_and_authorship_and_usergroups( diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 9063febc..e240bee4 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -1,6 +1,12 @@ +import stat from typing import Literal from uuid import uuid4 -from sqlmodel import Session, select +from regex import R +from sqlalchemy import exists, union +from sqlmodel import Session, select, and_, or_ +from src.db.usergroups import UserGroup +from src.db.usergroup_resources import UserGroupResource +from src.db.usergroup_user import UserGroupUser from src.db.organizations import Organization from src.db.trails import TrailRead from src.services.trail.trail import get_user_trail_with_orgid @@ -105,7 +111,6 @@ async def get_course_meta( ) trail = TrailRead.from_orm(trail) - return FullCourseReadWithTrail( **course.dict(), chapters=chapters, @@ -141,7 +146,7 @@ async def create_course( 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, org.org_uuid, course.course_uuid # type: ignore + thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore ) course.thumbnail_image = name_in_disk @@ -212,7 +217,7 @@ async def update_course_thumbnail( if thumbnail_file and thumbnail_file.filename: name_in_disk = f"{course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" await upload_thumbnail( - thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore + thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore ) # Update course @@ -326,26 +331,47 @@ async def get_courses_orgslug( page: int = 1, limit: int = 10, ): + + # Query for public courses 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) + + # Query for courses where the current user is an author + statement_author = ( + select(Course) + .join(Organization) + .join(ResourceAuthor, ResourceAuthor.user_id == current_user.id) + .where( + Organization.slug == org_slug, + ResourceAuthor.resource_uuid == Course.course_uuid, + ) ) - if current_user.id == 0: - statement = statement_public - else: - # RBAC check - await authorization_verify_if_user_is_anon(current_user.id) + # Query for courses where the current user is in a user group that has access to the course + statement_usergroup = ( + select(Course) + .join(Organization) + .join( + UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid + ) + .join( + UserGroupUser, UserGroupUser.usergroup_id == UserGroupResource.usergroup_id + ) + .where(Organization.slug == org_slug, UserGroupUser.user_id == current_user.id) + ) - statement = statement_all + # Combine the results + statement_complete = union( + statement_public, statement_author, statement_usergroup + ).subquery() - courses = db_session.exec(statement) + courses = db_session.execute(select([statement_complete])).all() - courses = [CourseRead(**course.dict(), authors=[]) for course in courses] + # TODO: I have no idea why this is necessary, but it is + courses = [CourseRead(**dict(course._mapping), authors=[]) for course in courses] # for every course, get the authors for course in courses: @@ -366,6 +392,7 @@ async def get_courses_orgslug( ## 🔒 RBAC Utils ## + async def rbac_check( request: Request, course_uuid: str, @@ -380,8 +407,10 @@ async def rbac_check( ) return res else: - res = await authorization_verify_based_on_roles_and_authorship_and_usergroups( - request, current_user.id, action, course_uuid, db_session + res = ( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( + request, current_user.id, action, course_uuid, db_session + ) ) return res else: diff --git a/apps/api/src/services/install/install.py b/apps/api/src/services/install/install.py index c5dd05e8..5c17cc88 100644 --- a/apps/api/src/services/install/install.py +++ b/apps/api/src/services/install/install.py @@ -135,6 +135,12 @@ async def install_default_elements(db_session: Session): action_update=True, action_delete=True, ), + usergroups=Permission( + action_create=True, + action_read=True, + action_update=True, + action_delete=True, + ), collections=Permission( action_create=True, action_read=True, @@ -183,6 +189,12 @@ async def install_default_elements(db_session: Session): action_update=True, action_delete=True, ), + usergroups=Permission( + action_create=True, + action_read=True, + action_update=True, + action_delete=True, + ), collections=Permission( action_create=True, action_read=True, @@ -231,6 +243,12 @@ async def install_default_elements(db_session: Session): action_update=False, action_delete=False, ), + usergroups=Permission( + action_create=False, + action_read=True, + action_update=False, + action_delete=False, + ), collections=Permission( action_create=False, action_read=True, diff --git a/apps/api/src/services/users/usergroups.py b/apps/api/src/services/users/usergroups.py index 381a76a1..c2f28926 100644 --- a/apps/api/src/services/users/usergroups.py +++ b/apps/api/src/services/users/usergroups.py @@ -1,9 +1,15 @@ from datetime import datetime import logging +from typing import Literal from uuid import uuid4 from fastapi import HTTPException, Request from sqlmodel import Session, select -from src.db.usergroup_ressources import UserGroupRessource +from src.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship_and_usergroups, + authorization_verify_if_element_is_public, + authorization_verify_if_user_is_anon, +) +from src.db.usergroup_resources import UserGroupResource from src.db.usergroup_user import UserGroupUser from src.db.organizations import Organization from src.db.usergroups import UserGroup, UserGroupCreate, UserGroupRead, UserGroupUpdate @@ -19,6 +25,15 @@ async def create_usergroup( usergroup = UserGroup.from_orm(usergroup_create) + # RBAC check + await rbac_check( + request, + usergroup_uuid="usergroup_X", + current_user=current_user, + action="create", + db_session=db_session, + ) + # Check if Organization exists statement = select(Organization).where(Organization.id == usergroup_create.org_id) result = db_session.exec(statement) @@ -60,11 +75,50 @@ async def read_usergroup_by_id( detail="UserGroup not found", ) + # RBAC check + await rbac_check( + request, + usergroup_uuid=usergroup.usergroup_uuid, + current_user=current_user, + action="read", + db_session=db_session, + ) + usergroup = UserGroupRead.from_orm(usergroup) return usergroup +async def read_usergroups_by_org_id( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + org_id: int, +) -> list[UserGroupRead]: + + statement = select(UserGroup).where(UserGroup.org_id == org_id) + usergroups = db_session.exec(statement).all() + + if not usergroups: + raise HTTPException( + status_code=404, + detail="UserGroups not found", + ) + + # RBAC check + await rbac_check( + request, + usergroup_uuid="usergroup_X", + current_user=current_user, + action="read", + db_session=db_session, + ) + + usergroups = [UserGroupRead.from_orm(usergroup) for usergroup in usergroups] + + return usergroups + + async def update_usergroup_by_id( request: Request, db_session: Session, @@ -82,6 +136,15 @@ async def update_usergroup_by_id( detail="UserGroup not found", ) + # RBAC check + await rbac_check( + request, + usergroup_uuid=usergroup.usergroup_uuid, + current_user=current_user, + action="update", + db_session=db_session, + ) + usergroup.name = usergroup_update.name usergroup.description = usergroup_update.description usergroup.update_date = str(datetime.now()) @@ -111,6 +174,15 @@ async def delete_usergroup_by_id( detail="UserGroup not found", ) + # RBAC check + await rbac_check( + request, + usergroup_uuid=usergroup.usergroup_uuid, + current_user=current_user, + action="delete", + db_session=db_session, + ) + db_session.delete(usergroup) db_session.commit() @@ -134,6 +206,15 @@ async def add_users_to_usergroup( detail="UserGroup not found", ) + # RBAC check + await rbac_check( + request, + usergroup_uuid=usergroup.usergroup_uuid, + current_user=current_user, + action="create", + db_session=db_session, + ) + user_ids_array = user_ids.split(",") for user_id in user_ids_array: @@ -177,6 +258,16 @@ async def remove_users_from_usergroup( detail="UserGroup not found", ) + # RBAC check + # RBAC check + await rbac_check( + request, + usergroup_uuid=usergroup.usergroup_uuid, + current_user=current_user, + action="delete", + db_session=db_session, + ) + user_ids_array = user_ids.split(",") for user_id in user_ids_array: @@ -192,12 +283,12 @@ async def remove_users_from_usergroup( return "Users removed from UserGroup successfully" -async def add_ressources_to_usergroup( +async def add_resources_to_usergroup( request: Request, db_session: Session, current_user: PublicUser | AnonymousUser, usergroup_id: int, - ressources_uuids: str, + resources_uuids: str, ) -> str: statement = select(UserGroup).where(UserGroup.id == usergroup_id) @@ -209,14 +300,23 @@ async def add_ressources_to_usergroup( detail="UserGroup not found", ) - ressources_uuids_array = ressources_uuids.split(",") + # RBAC check + await rbac_check( + request, + usergroup_uuid=usergroup.usergroup_uuid, + current_user=current_user, + action="create", + db_session=db_session, + ) - for ressource_uuid in ressources_uuids_array: - # TODO : Find a way to check if ressource exists + resources_uuids_array = resources_uuids.split(",") - usergroup_obj = UserGroupRessource( + for resource_uuid in resources_uuids_array: + # TODO : Find a way to check if resource exists + + usergroup_obj = UserGroupResource( usergroup_id=usergroup_id, - ressource_uuid=ressource_uuid, + resource_uuid=resource_uuid, org_id=usergroup.org_id, creation_date=str(datetime.now()), update_date=str(datetime.now()), @@ -226,15 +326,15 @@ async def add_ressources_to_usergroup( db_session.commit() db_session.refresh(usergroup_obj) - return "Ressources added to UserGroup successfully" + return "Resources added to UserGroup successfully" -async def remove_ressources_from_usergroup( +async def remove_resources_from_usergroup( request: Request, db_session: Session, current_user: PublicUser | AnonymousUser, usergroup_id: int, - ressources_uuids: str, + resources_uuids: str, ) -> str: statement = select(UserGroup).where(UserGroup.id == usergroup_id) @@ -246,20 +346,51 @@ async def remove_ressources_from_usergroup( detail="UserGroup not found", ) - ressources_uuids_array = ressources_uuids.split(",") + # RBAC check + await rbac_check( + request, + usergroup_uuid=usergroup.usergroup_uuid, + current_user=current_user, + action="delete", + db_session=db_session, + ) - for ressource_uuid in ressources_uuids_array: - statement = select(UserGroupRessource).where( - UserGroupRessource.ressource_uuid == ressource_uuid + resources_uuids_array = resources_uuids.split(",") + + for resource_uuid in resources_uuids_array: + statement = select(UserGroupResource).where( + UserGroupResource.resource_uuid == resource_uuid ) - usergroup_ressource = db_session.exec(statement).first() + usergroup_resource = db_session.exec(statement).first() - if usergroup_ressource: - db_session.delete(usergroup_ressource) + if usergroup_resource: + db_session.delete(usergroup_resource) db_session.commit() else: - logging.error( - f"Ressource with uuid {ressource_uuid} not found in UserGroup" - ) + logging.error(f"resource with uuid {resource_uuid} not found in UserGroup") - return "Ressources removed from UserGroup successfully" + return "Resources removed from UserGroup successfully" + + +## 🔒 RBAC Utils ## + + +async def rbac_check( + request: Request, + usergroup_uuid: 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_and_usergroups( + request, + current_user.id, + action, + usergroup_uuid, + db_session, + ) + + +## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 35235ff3..da5620db 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -453,7 +453,7 @@ async def authorize_user_action( request: Request, db_session: Session, current_user: PublicUser | AnonymousUser, - ressource_uuid: str, + resource_uuid: str, action: Literal["create", "read", "update", "delete"], ): # Get user @@ -468,7 +468,7 @@ async def authorize_user_action( # RBAC check authorized = await authorization_verify_based_on_roles_and_authorship_and_usergroups( - request, current_user.id, action, ressource_uuid, db_session + request, current_user.id, action, resource_uuid, db_session ) if authorized: