From e1b3b62e4084b654180d6e071537a4e23dda72ff Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 23 Mar 2024 09:08:56 +0000 Subject: [PATCH 1/7] feat: init ug database models + svcs --- apps/api/src/db/usergroup_ressources.py | 16 +++ apps/api/src/db/usergroups.py | 33 +++++++ apps/api/src/router.py | 2 + apps/api/src/routers/usergroups.py | 68 +++++++++++++ apps/api/src/services/users/usergroups.py | 114 ++++++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 apps/api/src/db/usergroup_ressources.py create mode 100644 apps/api/src/db/usergroups.py create mode 100644 apps/api/src/routers/usergroups.py create mode 100644 apps/api/src/services/users/usergroups.py diff --git a/apps/api/src/db/usergroup_ressources.py b/apps/api/src/db/usergroup_ressources.py new file mode 100644 index 00000000..f8796ca5 --- /dev/null +++ b/apps/api/src/db/usergroup_ressources.py @@ -0,0 +1,16 @@ +from typing import Optional +from sqlalchemy import Column, ForeignKey, Integer +from sqlmodel import Field, SQLModel + + +class UserGroupRessource(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 = "" + 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/usergroups.py b/apps/api/src/routers/usergroups.py new file mode 100644 index 00000000..83b69b4b --- /dev/null +++ b/apps/api/src/routers/usergroups.py @@ -0,0 +1,68 @@ +from typing import Literal +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile +from sqlmodel import Session +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 create_usergroup, delete_usergroup_by_id, read_usergroup_by_id, update_usergroup_by_id +from src.services.orgs.orgs import get_org_join_mechanism +from src.security.auth import get_current_user +from src.core.events.database import get_db_session + + +router = APIRouter() + + +@router.post("/", response_model=UserGroupCreate, tags=["usergroups"]) +async def api_create_user_without_org( + *, + 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.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) diff --git a/apps/api/src/services/users/usergroups.py b/apps/api/src/services/users/usergroups.py new file mode 100644 index 00000000..f7942166 --- /dev/null +++ b/apps/api/src/services/users/usergroups.py @@ -0,0 +1,114 @@ +from datetime import datetime +from uuid import uuid4 +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from src.db.organizations import Organization +from src.db.usergroups import UserGroup, UserGroupCreate, UserGroupRead, UserGroupUpdate +from src.db.users import AnonymousUser, PublicUser + + +async def create_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_create: UserGroupCreate, +) -> UserGroupRead: + + usergroup = UserGroup.from_orm(usergroup_create) + + # 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", + ) + + usergroup = UserGroupRead.from_orm(usergroup) + + return usergroup + + +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", + ) + + 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", + ) + + db_session.delete(usergroup) + db_session.commit() + + return "UserGroup deleted successfully" From 0df250c729b3d1b3bf3f6f748ef9bfe98e0c431f Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 26 Mar 2024 19:56:14 +0000 Subject: [PATCH 2/7] feat: refactor RBAC authorization functions to include usergroups --- apps/api/src/db/usergroup_user.py | 18 +++ apps/api/src/routers/orgs.py | 17 ++ apps/api/src/routers/usergroups.py | 91 ++++++++++- apps/api/src/security/rbac/rbac.py | 2 +- .../services/courses/activities/activities.py | 6 +- .../src/services/courses/activities/pdf.py | 4 +- .../src/services/courses/activities/video.py | 4 +- apps/api/src/services/courses/chapters.py | 6 +- apps/api/src/services/courses/collections.py | 6 +- apps/api/src/services/courses/courses.py | 11 +- apps/api/src/services/orgs/invites.py | 91 ++++++++++- apps/api/src/services/roles/roles.py | 4 +- apps/api/src/services/users/usergroups.py | 153 +++++++++++++++++- apps/api/src/services/users/users.py | 16 +- 14 files changed, 392 insertions(+), 37 deletions(-) create mode 100644 apps/api/src/db/usergroup_user.py 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/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 index 83b69b4b..5120ec7e 100644 --- a/apps/api/src/routers/usergroups.py +++ b/apps/api/src/routers/usergroups.py @@ -4,7 +4,16 @@ from sqlmodel import Session 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 create_usergroup, delete_usergroup_by_id, read_usergroup_by_id, update_usergroup_by_id +from src.services.users.usergroups import ( + add_ressources_to_usergroup, + add_users_to_usergroup, + create_usergroup, + delete_usergroup_by_id, + read_usergroup_by_id, + remove_ressources_from_usergroup, + remove_users_from_usergroup, + update_usergroup_by_id, +) from src.services.orgs.orgs import get_org_join_mechanism from src.security.auth import get_current_user from src.core.events.database import get_db_session @@ -13,8 +22,8 @@ from src.core.events.database import get_db_session router = APIRouter() -@router.post("/", response_model=UserGroupCreate, tags=["usergroups"]) -async def api_create_user_without_org( +@router.post("/", response_model=UserGroupRead, tags=["usergroups"]) +async def api_create_usergroup( *, request: Request, db_session: Session = Depends(get_db_session), @@ -40,6 +49,7 @@ async def api_get_usergroup( """ return await read_usergroup_by_id(request, db_session, current_user, usergroup_id) + @router.put("/{usergroup_id}", response_model=UserGroupRead, tags=["usergroups"]) async def api_update_usergroup( *, @@ -52,9 +62,12 @@ async def api_update_usergroup( """ Update UserGroup """ - return await update_usergroup_by_id(request, db_session, current_user, usergroup_id, usergroup_object) + return await update_usergroup_by_id( + request, db_session, current_user, usergroup_id, usergroup_object + ) -@router.delete("/{usergroup_id}", tags=["usergroups"]) + +@router.delete("/{usergroup_id}", tags=["usergroups"]) async def api_delete_usergroup( *, request: Request, @@ -66,3 +79,71 @@ async def api_delete_usergroup( 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_ressources", tags=["usergroups"]) +async def api_add_ressources_to_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_id: int, + ressource_uuids: str, +) -> str: + """ + Add Ressources to UserGroup + """ + return await add_ressources_to_usergroup( + request, db_session, current_user, usergroup_id, ressource_uuids + ) + + +@router.delete("/{usergroup_id}/remove_ressources", tags=["usergroups"]) +async def api_delete_ressources_from_usergroup( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + usergroup_id: int, + ressource_uuids: str, +) -> str: + """ + Delete Ressources from UserGroup + """ + return await remove_ressources_from_usergroup( + request, db_session, current_user, usergroup_id, ressource_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/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..b2872616 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, ) @@ -562,14 +562,14 @@ async def rbac_check( 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..9063febc 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -3,7 +3,6 @@ from uuid import uuid4 from sqlmodel import Session, select 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 +14,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, ) @@ -142,7 +141,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 +212,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 @@ -381,14 +380,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/orgs/invites.py b/apps/api/src/services/orgs/invites.py index cc5aae57..50f83488 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,17 @@ async def get_invite_codes( # Get invite codes invite_codes = r.keys(f"org_invite_code_*:org:{org.org_uuid}:code:*") + if not invite_codes: + raise HTTPException( + status_code=404, + detail="Invite codes not found", + ) + 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 +370,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 index f7942166..381a76a1 100644 --- a/apps/api/src/services/users/usergroups.py +++ b/apps/api/src/services/users/usergroups.py @@ -1,10 +1,13 @@ from datetime import datetime +import logging from uuid import uuid4 from fastapi import HTTPException, Request from sqlmodel import Session, select +from src.db.usergroup_ressources import UserGroupRessource +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 +from src.db.users import AnonymousUser, PublicUser, User async def create_usergroup( @@ -112,3 +115,151 @@ async def delete_usergroup_by_id( 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", + ) + + 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() + + 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", + ) + + user_ids_array = user_ids.split(",") + + for user_id in user_ids_array: + statement = select(UserGroupUser).where(UserGroupUser.user_id == user_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_ressources_to_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, + ressources_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", + ) + + ressources_uuids_array = ressources_uuids.split(",") + + for ressource_uuid in ressources_uuids_array: + # TODO : Find a way to check if ressource exists + + usergroup_obj = UserGroupRessource( + usergroup_id=usergroup_id, + ressource_uuid=ressource_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 "Ressources added to UserGroup successfully" + + +async def remove_ressources_from_usergroup( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + usergroup_id: int, + ressources_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", + ) + + ressources_uuids_array = ressources_uuids.split(",") + + for ressource_uuid in ressources_uuids_array: + statement = select(UserGroupRessource).where( + UserGroupRessource.ressource_uuid == ressource_uuid + ) + usergroup_ressource = db_session.exec(statement).first() + + if usergroup_ressource: + db_session.delete(usergroup_ressource) + db_session.commit() + else: + logging.error( + f"Ressource with uuid {ressource_uuid} not found in UserGroup" + ) + + return "Ressources removed from UserGroup successfully" diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 88d5eb09..35235ff3 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -10,7 +10,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,11 +124,15 @@ 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: + # Check if invite code contains UserGroup + #TODO + + + if not inviteCOde: raise HTTPException( status_code=400, detail="Invite code is incorrect", @@ -463,7 +467,7 @@ async def authorize_user_action( ) # RBAC check - authorized = await authorization_verify_based_on_roles_and_authorship( + authorized = await authorization_verify_based_on_roles_and_authorship_and_usergroups( request, current_user.id, action, ressource_uuid, db_session ) @@ -535,7 +539,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 +550,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 ) From a6152ef1f55501b7ea185d8e540e88cac06e9db3 Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 28 Mar 2024 11:20:42 +0000 Subject: [PATCH 3/7] feat: adapt UGs to roles --- apps/api/src/db/roles.py | 1 + ...p_ressources.py => usergroup_resources.py} | 4 +- apps/api/src/routers/usergroups.py | 43 +++-- apps/api/src/security/rbac/utils.py | 28 +-- apps/api/src/services/courses/chapters.py | 1 - apps/api/src/services/courses/courses.py | 61 ++++-- apps/api/src/services/install/install.py | 18 ++ apps/api/src/services/users/usergroups.py | 175 +++++++++++++++--- apps/api/src/services/users/users.py | 4 +- 9 files changed, 265 insertions(+), 70 deletions(-) rename apps/api/src/db/{usergroup_ressources.py => usergroup_resources.py} (85%) 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: From 4f75e6a90ac41788c3760cbc0c47637b041fd12b Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 30 Mar 2024 10:22:38 +0000 Subject: [PATCH 4/7] feat: init usergroup linking to a course --- apps/api/src/routers/usergroups.py | 14 ++ apps/api/src/services/users/usergroups.py | 51 ++++- .../course/[courseuuid]/[subpage]/page.tsx | 58 +++-- apps/web/app/orgs/[orgslug]/layout.tsx | 2 + .../EditCourseAccess/EditCourseAccess.tsx | 211 ++++++++++++++++++ .../EditCourseGeneral/EditCourseGeneral.tsx | 24 +- .../EditCourseGeneral/ThumbnailUpdate.tsx | 11 +- .../components/Dashboard/UI/BreadCrumbs.tsx | 1 + .../Dashboard/UI/CourseOverviewTop.tsx | 2 +- .../Dash/EditCourseAccess/LinkToUserGroup.tsx | 71 ++++++ .../components/StyledElements/Form/Form.tsx | 3 +- apps/web/services/usergroups/usergroups.ts | 35 +++ 12 files changed, 428 insertions(+), 55 deletions(-) create mode 100644 apps/web/components/Dashboard/Course/EditCourseAccess/EditCourseAccess.tsx create mode 100644 apps/web/components/Objects/Modals/Dash/EditCourseAccess/LinkToUserGroup.tsx create mode 100644 apps/web/services/usergroups/usergroups.ts diff --git a/apps/api/src/routers/usergroups.py b/apps/api/src/routers/usergroups.py index 7f8351c6..74160f43 100644 --- a/apps/api/src/routers/usergroups.py +++ b/apps/api/src/routers/usergroups.py @@ -9,6 +9,7 @@ from src.services.users.usergroups import ( add_users_to_usergroup, create_usergroup, delete_usergroup_by_id, + get_usergroups_by_resource, read_usergroup_by_id, read_usergroups_by_org_id, remove_resources_from_usergroup, @@ -64,6 +65,19 @@ async def api_get_usergroups( """ 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( diff --git a/apps/api/src/services/users/usergroups.py b/apps/api/src/services/users/usergroups.py index c2f28926..f5508d0f 100644 --- a/apps/api/src/services/users/usergroups.py +++ b/apps/api/src/services/users/usergroups.py @@ -119,6 +119,41 @@ async def read_usergroups_by_org_id( 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, @@ -258,7 +293,6 @@ async def remove_users_from_usergroup( detail="UserGroup not found", ) - # RBAC check # RBAC check await rbac_check( request, @@ -312,8 +346,21 @@ async def add_resources_to_usergroup( resources_uuids_array = resources_uuids.split(",") for resource_uuid in resources_uuids_array: - # TODO : Find a way to check if resource exists + # 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, 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..dc1155cd 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 (
-
+
-
+
@@ -46,6 +46,24 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
+ +
+
+ +
Access
+
+
+
@@ -65,7 +82,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
+
+
- {params.subpage == 'content' ? ( - - ) : ( - '' - )} - {params.subpage == 'general' ? ( - - ) : ( - '' - )} + {params.subpage == 'content' ? () : ('')} + {params.subpage == 'general' ? () : ('')} + {params.subpage == 'access' ? () : ('')}
diff --git a/apps/web/app/orgs/[orgslug]/layout.tsx b/apps/web/app/orgs/[orgslug]/layout.tsx index a00e1e77..97ccd9fa 100644 --- a/apps/web/app/orgs/[orgslug]/layout.tsx +++ b/apps/web/app/orgs/[orgslug]/layout.tsx @@ -1,6 +1,7 @@ 'use client' import { OrgProvider } from '@components/Contexts/OrgContext' import SessionProvider from '@components/Contexts/SessionContext' +import Toast from '@components/StyledElements/Toast/Toast' import '@styles/globals.css' export default function RootLayout({ @@ -12,6 +13,7 @@ export default function RootLayout({ }) { return (
+ {children} diff --git a/apps/web/components/Dashboard/Course/EditCourseAccess/EditCourseAccess.tsx b/apps/web/components/Dashboard/Course/EditCourseAccess/EditCourseAccess.tsx new file mode 100644 index 00000000..74de3d5d --- /dev/null +++ b/apps/web/components/Dashboard/Course/EditCourseAccess/EditCourseAccess.tsx @@ -0,0 +1,211 @@ +import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext' +import LinkToUserGroup from '@components/Objects/Modals/Dash/EditCourseAccess/LinkToUserGroup' +import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' +import Modal from '@components/StyledElements/Modal/Modal' +import { getAPIUrl } from '@services/config/config' +import { unLinkResourcesToUserGroup } from '@services/usergroups/usergroups' +import { swrFetcher } from '@services/utils/ts/requests' +import { Globe, Users, UsersRound, X } from 'lucide-react' +import React from 'react' +import toast from 'react-hot-toast' +import useSWR, { mutate } from 'swr' + +type EditCourseAccessProps = { + orgslug: string + course_uuid?: string +} + +function EditCourseAccess(props: EditCourseAccessProps) { + const [error, setError] = React.useState('') + + const course = useCourse() as any + const dispatchCourse = useCourseDispatch() as any + const courseStructure = course.courseStructure + const { data: usergroups } = useSWR( + courseStructure ? `${getAPIUrl()}usergroups/resource/${courseStructure.course_uuid}` : null, + swrFetcher + ) + const [isPublic, setIsPublic] = React.useState(courseStructure.public) + + + React.useEffect(() => { + // This code will run whenever form values are updated + if (isPublic !== courseStructure.public) { + dispatchCourse({ type: 'setIsNotSaved' }) + const updatedCourse = { + ...courseStructure, + public: isPublic, + } + dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse }) + } + }, [course, isPublic]) + return ( +
+ {' '} +
+
+
+

Access to the course

+

+ {' '} + Choose if want your course to be publicly available on the internet or only accessible to signed in users{' '} +

+
+
+ + {isPublic ? ( +
+ Active +
+ ) : null} +
+ +
+ Public +
+
+ The Course is publicly available on the internet, it is indexed by search engines and can be accessed by anyone +
+
+ +
+ } + functionToExecute={() => { + setIsPublic(true) + }} + status="info" + > + + {!isPublic ? ( +
+ Active +
+ ) : null} +
+ +
+ Users Only +
+
+ The Course is only accessible to signed in users, additionaly you can choose which UserGroups can access this course +
+
+ +
+ } + functionToExecute={() => { + setIsPublic(false) + }} + status="info" + > +
+ +
+
+ ) +} + + +function UserGroupsSection({ usergroups }: { usergroups: any[] }) { + const course = useCourse() as any + const [userGroupModal, setUserGroupModal] = React.useState(false) + + const removeUserGroupLink = async (usergroup_id: number) => { + const res = await unLinkResourcesToUserGroup(usergroup_id, course.courseStructure.course_uuid) + if (res.status === 200) { + toast.success('Successfully unliked from usergroup') + mutate(`${getAPIUrl()}usergroups/resource/${course.courseStructure.course_uuid}`) + } + else { + toast.error('Error ' + res.status + ': ' + res.data.detail) + } + } + + return ( + <> +
+

UserGroups

+

+ {' '} + Choose which UserGroups can access this course{' '} +

+
+ + + + + + + + <> + + {usergroups?.map((usergroup: any) => ( + + + + + ))} + + +
NameActions
{usergroup.name} + + + Delete link + + } + functionToExecute={() => { + removeUserGroupLink(usergroup.id) + }} + status="warning" + > +
+
+ + setUserGroupModal(!userGroupModal) + } + minHeight="no-min" + dialogContent={ + + + } + dialogTitle="Link Course to a UserGroup" + dialogDescription={ + 'Choose which UserGroups can access this course' + } + dialogTrigger={ + + } + /> + +
+ ) +} + +export default EditCourseAccess \ No newline at end of file diff --git a/apps/web/components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral.tsx b/apps/web/components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral.tsx index 896e6ca6..84129421 100644 --- a/apps/web/components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral.tsx +++ b/apps/web/components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral.tsx @@ -116,10 +116,11 @@ function EditCourseGeneral(props: EditCourseStructureProps) { message={formik.errors.description} /> -