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_resources.py b/apps/api/src/db/usergroup_resources.py new file mode 100644 index 00000000..f9069b29 --- /dev/null +++ b/apps/api/src/db/usergroup_resources.py @@ -0,0 +1,16 @@ +from typing import Optional +from sqlalchemy import Column, ForeignKey, Integer +from sqlmodel import Field, SQLModel + + +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")) + ) + resource_uuid: str = "" + org_id: int = Field( + sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE")) + ) + creation_date: str = "" + update_date: str = "" diff --git a/apps/api/src/db/usergroup_user.py b/apps/api/src/db/usergroup_user.py new file mode 100644 index 00000000..b84fc904 --- /dev/null +++ b/apps/api/src/db/usergroup_user.py @@ -0,0 +1,18 @@ +from typing import Optional +from sqlalchemy import Column, ForeignKey, Integer +from sqlmodel import Field, SQLModel + + +class UserGroupUser(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")) + ) + user_id: int = Field( + sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) + ) + org_id: int = Field( + sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE")) + ) + creation_date: str = "" + update_date: str = "" diff --git a/apps/api/src/db/usergroups.py b/apps/api/src/db/usergroups.py new file mode 100644 index 00000000..dc71ea73 --- /dev/null +++ b/apps/api/src/db/usergroups.py @@ -0,0 +1,33 @@ +from typing import Optional +from sqlalchemy import Column, ForeignKey, Integer +from sqlmodel import Field, SQLModel + + +class UserGroupBase(SQLModel): + name: str + description: str + +class UserGroup(UserGroupBase, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + org_id: int = Field( + sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE")) + ) + usergroup_uuid: str = "" + creation_date: str = "" + update_date: str = "" + +class UserGroupCreate(UserGroupBase): + org_id: int = Field(default=None, foreign_key="organization.id") + pass + +class UserGroupUpdate(UserGroupBase): + name: str + description: str + +class UserGroupRead(UserGroupBase): + id: int + org_id: int = Field(default=None, foreign_key="organization.id") + usergroup_uuid: str + creation_date: str + update_date: str + pass diff --git a/apps/api/src/router.py b/apps/api/src/router.py index 2908d7ae..fd171096 100644 --- a/apps/api/src/router.py +++ b/apps/api/src/router.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends +from src.routers import usergroups from src.routers import blocks, dev, trail, users, auth, orgs, roles from src.routers.ai import ai from src.routers.courses import chapters, collections, courses, activities @@ -12,6 +13,7 @@ v1_router = APIRouter(prefix="/api/v1") # API Routes v1_router.include_router(users.router, prefix="/users", tags=["users"]) +v1_router.include_router(usergroups.router, prefix="/usergroups", tags=["usergroups"]) v1_router.include_router(auth.router, prefix="/auth", tags=["auth"]) v1_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"]) v1_router.include_router(roles.router, prefix="/roles", tags=["roles"]) diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index 80a2d944..cd7f7d9b 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Request, UploadFile from sqlmodel import Session from src.services.orgs.invites import ( create_invite_code, + create_invite_code_with_usergroup, delete_invite_code, get_invite_code, get_invite_codes, @@ -162,6 +163,22 @@ async def api_create_invite_code( return await create_invite_code(request, org_id, current_user, db_session) +@router.post("/{org_id}/invites_with_usergroups") +async def api_create_invite_code_with_ug( + request: Request, + org_id: int, + usergroup_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Create invite code + """ + return await create_invite_code_with_usergroup( + request, org_id, usergroup_id, current_user, db_session + ) + + @router.get("/{org_id}/invites") async def api_get_invite_codes( request: Request, diff --git a/apps/api/src/routers/usergroups.py b/apps/api/src/routers/usergroups.py new file mode 100644 index 00000000..5c2c30b4 --- /dev/null +++ b/apps/api/src/routers/usergroups.py @@ -0,0 +1,197 @@ +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session +from src.db.usergroups import UserGroupCreate, UserGroupRead, UserGroupUpdate +from src.db.users import PublicUser, UserRead +from src.services.users.usergroups import ( + add_resources_to_usergroup, + add_users_to_usergroup, + create_usergroup, + delete_usergroup_by_id, + get_usergroups_by_resource, + get_users_linked_to_usergroup, + read_usergroup_by_id, + read_usergroups_by_org_id, + remove_resources_from_usergroup, + remove_users_from_usergroup, + update_usergroup_by_id, +) +from src.security.auth import get_current_user +from src.core.events.database import get_db_session + + +router = APIRouter() + + +@router.post("/", response_model=UserGroupRead, tags=["usergroups"]) +async def api_create_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_object: UserGroupCreate, +) -> UserGroupRead: + """ + Create User + """ + return await create_usergroup(request, db_session, current_user, usergroup_object) + + +@router.get("/{usergroup_id}", response_model=UserGroupRead, tags=["usergroups"]) +async def api_get_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_id: int, +) -> UserGroupRead: + """ + Get UserGroup + """ + return await read_usergroup_by_id(request, db_session, current_user, usergroup_id) + + +@router.get("/{usergroup_id}/users", response_model=list[UserRead], tags=["usergroups"]) +async def api_get_users_linked_to_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_id: int, +) -> list[UserRead]: + """ + Get Users linked to UserGroup + """ + return await get_users_linked_to_usergroup( + 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.get( + "/resource/{resource_uuid}", response_model=list[UserGroupRead], tags=["usergroups"] +) +async def api_get_usergroupsby_resource( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + resource_uuid: str, +) -> list[UserGroupRead]: + """ + Get UserGroups by Org + """ + return await get_usergroups_by_resource( + request, db_session, current_user, resource_uuid + ) + + +@router.put("/{usergroup_id}", response_model=UserGroupRead, tags=["usergroups"]) +async def api_update_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_id: int, + usergroup_object: UserGroupUpdate, +) -> UserGroupRead: + """ + Update UserGroup + """ + return await update_usergroup_by_id( + request, db_session, current_user, usergroup_id, usergroup_object + ) + + +@router.delete("/{usergroup_id}", tags=["usergroups"]) +async def api_delete_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_id: int, +) -> str: + """ + Delete UserGroup + """ + return await delete_usergroup_by_id(request, db_session, current_user, usergroup_id) + + +@router.post("/{usergroup_id}/add_users", tags=["usergroups"]) +async def api_add_users_to_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_id: int, + user_ids: str, +) -> str: + """ + Add Users to UserGroup + """ + return await add_users_to_usergroup( + request, db_session, current_user, usergroup_id, user_ids + ) + + +@router.delete("/{usergroup_id}/remove_users", tags=["usergroups"]) +async def api_delete_users_from_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_id: int, + user_ids: str, +) -> str: + """ + Delete Users from UserGroup + """ + return await remove_users_from_usergroup( + request, db_session, current_user, usergroup_id, user_ids + ) + + +@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, + resource_uuids: str, +) -> str: + """ + Add Resources to UserGroup + """ + return await add_resources_to_usergroup( + request, db_session, current_user, usergroup_id, resource_uuids + ) + + +@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, + resource_uuids: str, +) -> str: + """ + Delete Resources from UserGroup + """ + return await remove_resources_from_usergroup( + request, db_session, current_user, usergroup_id, resource_uuids + ) diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index 01f5a343..4ce18736 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -143,7 +143,7 @@ async def authorization_verify_based_on_org_admin_status( # Tested and working -async def authorization_verify_based_on_roles_and_authorship( +async def authorization_verify_based_on_roles_and_authorship_and_usergroups( request: Request, user_id: int, action: Literal["read", "update", "delete", "create"], 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/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index 81f1f501..3b970818 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -3,7 +3,7 @@ from sqlmodel import Session, select from src.db.courses import Course from src.db.chapters import Chapter from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship, + authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -238,14 +238,14 @@ async def rbac_check( ) return res else: - res = await authorization_verify_based_on_roles_and_authorship( + res = await authorization_verify_based_on_roles_and_authorship_and_usergroups( 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( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, diff --git a/apps/api/src/services/courses/activities/pdf.py b/apps/api/src/services/courses/activities/pdf.py index 5a4d24f1..0fbbe5aa 100644 --- a/apps/api/src/services/courses/activities/pdf.py +++ b/apps/api/src/services/courses/activities/pdf.py @@ -3,7 +3,7 @@ from src.db.courses import Course from src.db.organizations import Organization from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship, + authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_user_is_anon, ) from src.db.chapters import Chapter @@ -150,7 +150,7 @@ async def rbac_check( ): await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index 16bcf196..5070814c 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -5,7 +5,7 @@ from src.db.organizations import Organization from pydantic import BaseModel from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship, + authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_user_is_anon, ) from src.db.chapters import Chapter @@ -232,7 +232,7 @@ async def rbac_check( ): await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index 1e5895b2..31b3d7ef 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -4,7 +4,7 @@ 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_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -559,17 +559,16 @@ 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( + res = await authorization_verify_based_on_roles_and_authorship_and_usergroups( 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( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, diff --git a/apps/api/src/services/courses/collections.py b/apps/api/src/services/courses/collections.py index 8ee257e0..51999804 100644 --- a/apps/api/src/services/courses/collections.py +++ b/apps/api/src/services/courses/collections.py @@ -4,7 +4,7 @@ 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_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -297,14 +297,14 @@ async def rbac_check( detail="User rights : You are not allowed to read this collection", ) else: - res = await authorization_verify_based_on_roles_and_authorship( + res = await authorization_verify_based_on_roles_and_authorship_and_usergroups( 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( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index cc007a56..3e2458ea 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -1,9 +1,11 @@ from typing import Literal from uuid import uuid4 +from sqlalchemy import union from sqlmodel import Session, select +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 from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum from src.db.users import PublicUser, AnonymousUser, User, UserRead @@ -15,7 +17,7 @@ from src.db.courses import ( FullCourseReadWithTrail, ) from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship, + authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_element_is_public, authorization_verify_if_user_is_anon, ) @@ -106,7 +108,6 @@ async def get_course_meta( ) trail = TrailRead.from_orm(trail) - return FullCourseReadWithTrail( **course.dict(), chapters=chapters, @@ -142,7 +143,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 + thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore ) course.thumbnail_image = name_in_disk @@ -213,7 +214,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 + thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore ) # Update course @@ -327,26 +328,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: @@ -367,6 +389,7 @@ async def get_courses_orgslug( ## 🔒 RBAC Utils ## + async def rbac_check( request: Request, course_uuid: str, @@ -381,14 +404,16 @@ async def rbac_check( ) return res else: - res = await authorization_verify_based_on_roles_and_authorship( - 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: await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, 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/orgs/invites.py b/apps/api/src/services/orgs/invites.py index cc5aae57..c8cb5f69 100644 --- a/apps/api/src/services/orgs/invites.py +++ b/apps/api/src/services/orgs/invites.py @@ -94,6 +94,85 @@ async def create_invite_code( return inviteCodeObject +async def create_invite_code_with_usergroup( + request: Request, + org_id: int, + usergroup_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Redis init + LH_CONFIG = get_learnhouse_config() + redis_conn_string = LH_CONFIG.redis_config.redis_connection_string + + if not redis_conn_string: + raise HTTPException( + status_code=500, + detail="Redis connection string not found", + ) + + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) + + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "update", db_session) + + # Connect to Redis + r = redis.Redis.from_url(redis_conn_string) + + if not r: + raise HTTPException( + status_code=500, + detail="Could not connect to Redis", + ) + + # Check if this org has more than 6 invite codes + invite_codes = r.keys(f"*:org:{org.org_uuid}:code:*") + + if len(invite_codes) >= 6: + raise HTTPException( + status_code=400, + detail="Organization has reached the maximum number of invite codes", + ) + + # Generate invite code + def generate_code(length=5): + letters_and_digits = string.ascii_letters + string.digits + return "".join(random.choice(letters_and_digits) for _ in range(length)) + + generated_invite_code = generate_code() + invite_code_uuid = f"org_invite_code_{uuid.uuid4()}" + + # time to live in days to seconds + ttl = int(timedelta(days=365).total_seconds()) + + inviteCodeObject = { + "invite_code": generated_invite_code, + "invite_code_uuid": invite_code_uuid, + "invite_code_expires": ttl, + "usergroup_id": usergroup_id, + "invite_code_type": "signup", + "created_at": datetime.now().isoformat(), + "created_by": current_user.user_uuid, + } + + r.set( + f"{invite_code_uuid}:org:{org.org_uuid}:code:{generated_invite_code}", + json.dumps(inviteCodeObject), + ex=ttl, + ) + + return inviteCodeObject + + async def get_invite_codes( request: Request, org_id: int, @@ -136,11 +215,13 @@ async def get_invite_codes( # Get invite codes invite_codes = r.keys(f"org_invite_code_*:org:{org.org_uuid}:code:*") + + invite_codes_list = [] - for invite_code in invite_codes: + for invite_code in invite_codes: # type: ignore invite_code = r.get(invite_code) - invite_code = json.loads(invite_code) # type: ignore + invite_code = json.loads(invite_code) # type: ignore invite_codes_list.append(invite_code) return invite_codes_list @@ -285,7 +366,7 @@ def send_invite_email( # Send email if invite: invite = r.get(invite[0]) - invite = json.loads(invite) # type: ignore + invite = json.loads(invite) # type: ignore # send email send_email( diff --git a/apps/api/src/services/roles/roles.py b/apps/api/src/services/roles/roles.py index a5d46253..84c03b00 100644 --- a/apps/api/src/services/roles/roles.py +++ b/apps/api/src/services/roles/roles.py @@ -2,7 +2,7 @@ from typing import Literal from uuid import uuid4 from sqlmodel import Session, select from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship, + authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_user_is_anon, ) from src.db.users import AnonymousUser, PublicUser @@ -133,7 +133,7 @@ async def rbac_check( ): await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, role_uuid, db_session ) diff --git a/apps/api/src/services/users/usergroups.py b/apps/api/src/services/users/usergroups.py new file mode 100644 index 00000000..1ad62e4b --- /dev/null +++ b/apps/api/src/services/users/usergroups.py @@ -0,0 +1,491 @@ +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.security.rbac.rbac import ( + authorization_verify_based_on_roles_and_authorship_and_usergroups, + 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 +from src.db.users import AnonymousUser, PublicUser, User, UserRead + + +async def create_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_create: UserGroupCreate, +) -> UserGroupRead: + + 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) + + if not result.first(): + raise HTTPException( + status_code=400, + detail="Organization does not exist", + ) + + # Complete the object + usergroup.usergroup_uuid = f"usergroup_{uuid4()}" + usergroup.creation_date = str(datetime.now()) + usergroup.update_date = str(datetime.now()) + + # Save the object + db_session.add(usergroup) + db_session.commit() + db_session.refresh(usergroup) + + usergroup = UserGroupRead.from_orm(usergroup) + + return usergroup + + +async def read_usergroup_by_id( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, +) -> UserGroupRead: + + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + + if not usergroup: + raise HTTPException( + status_code=404, + 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 get_users_linked_to_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, +) -> list[UserRead]: + + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + + if not usergroup: + raise HTTPException( + status_code=404, + 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, + ) + + statement = select(UserGroupUser).where(UserGroupUser.usergroup_id == usergroup_id) + usergroup_users = db_session.exec(statement).all() + + user_ids = [usergroup_user.user_id for usergroup_user in usergroup_users] + + # get users + users = [] + for user_id in user_ids: + statement = select(User).where(User.id == user_id) + user = db_session.exec(statement).first() + users.append(user) + + users = [UserRead.from_orm(user) for user in users] + + return users + + +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() + + # 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 get_usergroups_by_resource( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + resource_uuid: str, +) -> list[UserGroupRead]: + + statement = select(UserGroupResource).where( + UserGroupResource.resource_uuid == resource_uuid + ) + usergroup_resources = db_session.exec(statement).all() + + # RBAC check + await rbac_check( + request, + usergroup_uuid="usergroup_X", + current_user=current_user, + action="read", + db_session=db_session, + ) + + usergroup_ids = [usergroup.usergroup_id for usergroup in usergroup_resources] + + # get usergroups + usergroups = [] + for usergroup_id in usergroup_ids: + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + usergroups.append(usergroup) + + usergroups = [UserGroupRead.from_orm(usergroup) for usergroup in usergroups] + + return usergroups + + +async def update_usergroup_by_id( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, + usergroup_update: UserGroupUpdate, +) -> UserGroupRead: + + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + + if not usergroup: + raise HTTPException( + status_code=404, + 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()) + + db_session.add(usergroup) + db_session.commit() + db_session.refresh(usergroup) + + usergroup = UserGroupRead.from_orm(usergroup) + + return usergroup + + +async def delete_usergroup_by_id( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, +) -> str: + + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + + if not usergroup: + raise HTTPException( + status_code=404, + 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() + + return "UserGroup deleted successfully" + + +async def add_users_to_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, + user_ids: str, +) -> str: + + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + + if not usergroup: + raise HTTPException( + status_code=404, + 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: + statement = select(User).where(User.id == user_id) + user = db_session.exec(statement).first() + + # Check if User is already Linked to UserGroup + statement = select(UserGroupUser).where( + UserGroupUser.usergroup_id == usergroup_id, + UserGroupUser.user_id == user_id, + ) + usergroup_user = db_session.exec(statement).first() + + if usergroup_user: + logging.error(f"User with id {user_id} already exists in UserGroup") + continue + + if user: + # Add user to UserGroup + if user.id is not None: + usergroup_obj = UserGroupUser( + usergroup_id=usergroup_id, + user_id=user.id, + org_id=usergroup.org_id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + db_session.add(usergroup_obj) + db_session.commit() + db_session.refresh(usergroup_obj) + else: + logging.error(f"User with id {user_id} not found") + + return "Users added to UserGroup successfully" + + +async def remove_users_from_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, + user_ids: str, +) -> str: + + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + + if not usergroup: + raise HTTPException( + status_code=404, + 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, + ) + + user_ids_array = user_ids.split(",") + + for user_id in user_ids_array: + statement = select(UserGroupUser).where(UserGroupUser.user_id == user_id, UserGroupUser.usergroup_id == usergroup_id) + usergroup_user = db_session.exec(statement).first() + + if usergroup_user: + db_session.delete(usergroup_user) + db_session.commit() + else: + logging.error(f"User with id {user_id} not found in UserGroup") + + return "Users removed from UserGroup successfully" + + +async def add_resources_to_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, + resources_uuids: str, +) -> str: + + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + + if not usergroup: + raise HTTPException( + status_code=404, + 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, + ) + + resources_uuids_array = resources_uuids.split(",") + + for resource_uuid in resources_uuids_array: + # Check if a link between UserGroup and Resource already exists + statement = select(UserGroupResource).where( + UserGroupResource.usergroup_id == usergroup_id, + UserGroupResource.resource_uuid == resource_uuid, + ) + usergroup_resource = db_session.exec(statement).first() + + if usergroup_resource: + raise HTTPException( + status_code=400, + detail=f"Resource {resource_uuid} already exists in UserGroup", + ) + continue + + # TODO : Find a way to check if resource really exists + usergroup_obj = UserGroupResource( + usergroup_id=usergroup_id, + resource_uuid=resource_uuid, + org_id=usergroup.org_id, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + db_session.add(usergroup_obj) + db_session.commit() + db_session.refresh(usergroup_obj) + + return "Resources added to UserGroup successfully" + + +async def remove_resources_from_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, + resources_uuids: str, +) -> str: + + statement = select(UserGroup).where(UserGroup.id == usergroup_id) + usergroup = db_session.exec(statement).first() + + if not usergroup: + raise HTTPException( + status_code=404, + 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, + ) + + resources_uuids_array = resources_uuids.split(",") + + for resource_uuid in resources_uuids_array: + statement = select(UserGroupResource).where( + UserGroupResource.resource_uuid == resource_uuid + ) + usergroup_resource = db_session.exec(statement).first() + + if usergroup_resource: + db_session.delete(usergroup_resource) + db_session.commit() + else: + logging.error(f"resource with uuid {resource_uuid} not found in UserGroup") + + 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 88d5eb09..70d6ddc4 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -3,6 +3,7 @@ from typing import Literal from uuid import uuid4 from fastapi import HTTPException, Request, UploadFile, status from sqlmodel import Session, select +from src.services.users.usergroups import add_users_to_usergroup from src.services.users.emails import ( send_account_creation_email, ) @@ -10,7 +11,7 @@ from src.services.orgs.invites import get_invite_code from src.services.users.avatars import upload_avatar from src.db.roles import Role, RoleRead from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship, + authorization_verify_based_on_roles_and_authorship_and_usergroups, authorization_verify_if_user_is_anon, ) from src.db.organizations import Organization, OrganizationRead @@ -124,16 +125,27 @@ async def create_user_with_invite( ): # Check if invite code exists - isInviteCodeCorrect = await get_invite_code( + inviteCode = await get_invite_code( request, org_id, invite_code, current_user, db_session ) - if not isInviteCodeCorrect: + if not inviteCode: raise HTTPException( status_code=400, detail="Invite code is incorrect", ) + # Check if invite code contains UserGroup + if inviteCode.usergroup_id: + # Add user to UserGroup + await add_users_to_usergroup( + request, + db_session, + current_user, + inviteCode.usergroup_id, + user_object.username, + ) + user = await create_user(request, db_session, current_user, user_object, org_id) return user @@ -346,6 +358,7 @@ async def update_user_password( return user + async def read_user_by_id( request: Request, db_session: Session, @@ -449,7 +462,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 @@ -463,8 +476,10 @@ async def authorize_user_action( ) # RBAC check - authorized = await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, action, ressource_uuid, db_session + authorized = ( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( + request, current_user.id, action, resource_uuid, db_session + ) ) if authorized: @@ -535,7 +550,7 @@ async def rbac_check( if current_user.id == 0: # if user is anonymous return True else: - await authorization_verify_based_on_roles_and_authorship( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, "create", "user_x", db_session ) @@ -546,7 +561,7 @@ async def rbac_check( if current_user.user_uuid == user_uuid: return True - await authorization_verify_based_on_roles_and_authorship( + await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, user_uuid, db_session ) diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index 6cfe4c83..b4cab9d6 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -7,7 +7,8 @@ import Link from 'next/link' import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop' import { motion } from 'framer-motion' import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral' -import { GalleryVerticalEnd, Info } from 'lucide-react' +import { GalleryVerticalEnd, Info, Lock, UserRoundCog } from 'lucide-react' +import EditCourseAccess from '@components/Dashboard/Course/EditCourseAccess/EditCourseAccess' export type CourseOverviewParams = { orgslug: string @@ -24,9 +25,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) { return (
Submit
| Name | +Actions | +
|---|---|
| {usergroup.name} | +
+ |
+
| UserGroup | +Description | +Manage Users | +Actions | +
|---|---|---|---|
| {usergroup.name} | +{usergroup.description} | +
+ |
+
+
+ |
+
| User | +Linked | +Actions | +
|---|---|---|
| + + {user.user.first_name + ' ' + user.user.last_name} + + + @{user.user.username} + + | +
+ {isUserPartOfGroup(user.user.id) ?
+
+
+ :
+
+
+ }
+ |
+ + + + + | +