diff --git a/apps/api/src/db/course_authors.py b/apps/api/src/db/course_authors.py deleted file mode 100644 index 1edaa47f..00000000 --- a/apps/api/src/db/course_authors.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Optional -from sqlmodel import Field, SQLModel -from enum import Enum - - -class CourseAuthorshipEnum(str, Enum): - CREATOR = "CREATOR" - MAINTAINER = "MAINTAINER" - REPORTER = "REPORTER" - - -class CourseAuthor(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - course_id: int = Field(default=None, foreign_key="course.id") - user_id: int = Field(default=None, foreign_key="user.id") - authorship: CourseAuthorshipEnum = CourseAuthorshipEnum.CREATOR - creation_date: str - update_date: str diff --git a/apps/api/src/db/resource_authors.py b/apps/api/src/db/resource_authors.py new file mode 100644 index 00000000..3befb846 --- /dev/null +++ b/apps/api/src/db/resource_authors.py @@ -0,0 +1,20 @@ +from enum import Enum +from typing import Optional, Union +from pydantic import BaseModel +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +class ResourceAuthorshipEnum(str, Enum): + CREATOR = "CREATOR" + MAINTAINER = "MAINTAINER" + REPORTER = "REPORTER" + + +class ResourceAuthor(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + resource_uuid: str + user_id: int = Field(default=None, foreign_key="user.id") + authorship: ResourceAuthorshipEnum = ResourceAuthorshipEnum.CREATOR + creation_date: str = "" + update_date: str = "" diff --git a/apps/api/src/db/users.py b/apps/api/src/db/users.py index 99f19fd7..8042ad3f 100644 --- a/apps/api/src/db/users.py +++ b/apps/api/src/db/users.py @@ -36,7 +36,7 @@ class UserRead(UserBase): class PublicUser(UserRead): pass -class AnonymousUser(UserRead): +class AnonymousUser(SQLModel): id: str = "anonymous" username: str = "anonymous" diff --git a/apps/api/src/routers/roles.py b/apps/api/src/routers/roles.py index 114cc255..6fcb600d 100644 --- a/apps/api/src/routers/roles.py +++ b/apps/api/src/routers/roles.py @@ -4,7 +4,7 @@ from src.core.events.database import get_db_session from src.db.roles import RoleCreate, RoleUpdate from src.security.auth import get_current_user from src.services.roles.roles import create_role, delete_role, read_role, update_role -from src.services.users.schemas.users import PublicUser +from src.db.users import PublicUser router = APIRouter() diff --git a/apps/api/src/security/auth.py b/apps/api/src/security/auth.py index d6691864..3ddad99b 100644 --- a/apps/api/src/security/auth.py +++ b/apps/api/src/security/auth.py @@ -1,6 +1,6 @@ from sqlmodel import Session from src.core.events.database import get_db_session -from src.db.users import User, UserRead +from src.db.users import AnonymousUser, User, UserRead from src.services.users.users import security_get_user from config.config import get_learnhouse_config from pydantic import BaseModel @@ -9,7 +9,6 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from datetime import datetime, timedelta from src.services.dev.dev import isDevModeEnabled -from src.services.users.schemas.users import AnonymousUser, PublicUser from src.services.users.users import security_verify_password from src.security.security import ALGORITHM, SECRET_KEY from fastapi_jwt_auth import AuthJWT @@ -100,6 +99,6 @@ async def get_current_user( return AnonymousUser() -async def non_public_endpoint(current_user: PublicUser): +async def non_public_endpoint(current_user: UserRead | AnonymousUser): if isinstance(current_user, AnonymousUser): raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index ea2e1838..898ea594 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -1,17 +1,24 @@ +from math import e from typing import Literal from fastapi import HTTPException, status, Request -from src.security.rbac.utils import check_element_type, get_id_identifier_of_element -from src.services.roles.schemas.roles import RoleInDB -from src.services.users.schemas.users import UserRolesInOrganization +from sqlalchemy import func, null, or_ +from sqlmodel import Session, select +from src.db.collections import Collection +from src.db.courses import Course +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum +from src.db.roles import Role +from src.db.user_organizations import UserOrganization +from src.security.rbac.utils import check_element_type async def authorization_verify_if_element_is_public( request, - element_id: str, + element_uuid: str, user_id: str, action: Literal["read"], + db_session: Session, ): - element_nature = await check_element_type(element_id) + element_nature = await check_element_type(element_uuid) # Verifies if the element is public if ( @@ -20,10 +27,12 @@ async def authorization_verify_if_element_is_public( and user_id == "anonymous" ): if element_nature == "courses": - courses = request.app.db["courses"] - course = await courses.find_one({"course_id": element_id}) + statement = select(Course).where( + Course.public == True, Course.course_uuid == element_uuid + ) + course = db_session.exec(statement).first() - if course["public"]: + if course: return True else: raise HTTPException( @@ -32,10 +41,12 @@ async def authorization_verify_if_element_is_public( ) if element_nature == "collections": - collections = request.app.db["collections"] - collection = await collections.find_one({"collection_id": element_id}) + statement = select(Collection).where( + Collection.public == True, Collection.collection_uuid == element_uuid + ) + collection = db_session.exec(statement).first() - if collection["public"]: + if collection: return True else: raise HTTPException( @@ -53,67 +64,65 @@ async def authorization_verify_if_user_is_author( request, user_id: str, action: Literal["read", "update", "delete", "create"], - element_id: str, + element_uuid: str, + db_session: Session, ): if action == "update" or "delete" or "read": - element_nature = await check_element_type(element_id) - elements = request.app.db[element_nature] - element_identifier = await get_id_identifier_of_element(element_id) - element = await elements.find_one({element_identifier: element_id}) - if user_id in element["authors"]: - return True + statement = select(ResourceAuthor).where( + ResourceAuthor.resource_uuid == element_uuid + ) + resource_author = db_session.exec(statement).first() + + if resource_author: + if resource_author.user_id == user_id: + if (resource_author.authorship == ResourceAuthorshipEnum.CREATOR) or ( + resource_author.authorship == ResourceAuthorshipEnum.MAINTAINER + ): + return True + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User rights (authorship) : You don't have the right to perform this action", + ) else: - return False - else: - return False + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Wrong action (create)", + ) async def authorization_verify_based_on_roles( request: Request, user_id: str, action: Literal["read", "update", "delete", "create"], - roles_list: list[UserRolesInOrganization], - element_id: str, + element_uuid: str, + db_session: Session, ): - element_type = await check_element_type(element_id) - element = request.app.db[element_type] - roles = request.app.db["roles"] + element_type = await check_element_type(element_uuid) - # Get the element - element_identifier = await get_id_identifier_of_element(element_id) - element = await element.find_one({element_identifier: element_id}) + # Get user roles bound to an organization and standard roles + statement = ( + select(Role) + .join(UserOrganization) + .where(UserOrganization.user_id == user_id) + .where((UserOrganization.id == Role.org_id) | (UserOrganization.id == null)) + ) - # Get the roles of the user - roles_id_list = [role["role_id"] for role in roles_list] - roles = await roles.find({"role_id": {"$in": roles_id_list}}).to_list(length=100) + user_roles_in_organization_and_standard_roles = db_session.exec(statement).all() - async def checkRoles(): - # Check Roles - for role in roles: - role = RoleInDB(**role) - if role.elements[element_type][f"action_{action}"] is True: + # Find in roles list if there is a role that matches users action for this type of element + for role in user_roles_in_organization_and_standard_roles: + role = Role.from_orm(role) + if role.rights: + rights = role.rights + if rights[element_type][f"action_{action}"] is True: return True else: return False - - async def checkOrgRoles(): - # Check Org Roles - users = request.app.db["users"] - user = await users.find_one({"user_id": user_id}) - if element is not None: - for org in user["orgs"]: - if org["org_id"] == element["org_id"]: - if org["org_role"] == "owner" or org["org_role"] == "editor": - return True - else: - return False - - if await checkRoles() or await checkOrgRoles(): - return True else: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="User rights (roless) : You don't have the right to perform this action", + detail="User rights (roles) : You don't have the right to perform this action", ) @@ -121,15 +130,15 @@ async def authorization_verify_based_on_roles_and_authorship( request: Request, user_id: str, action: Literal["read", "update", "delete", "create"], - roles_list: list[UserRolesInOrganization], - element_id: str, + element_uuid: str, + db_session: Session, ): isAuthor = await authorization_verify_if_user_is_author( - request, user_id, action, element_id + request, user_id, action, element_uuid, db_session ) isRole = await authorization_verify_based_on_roles( - request, user_id, action, roles_list, element_id + request, user_id, action, element_uuid, db_session ) if isAuthor or isRole: diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index cc69524f..afe4b614 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -1,8 +1,10 @@ import json +import resource from typing import Literal from uuid import uuid4 from sqlmodel import Session, select -from src.db.course_authors import CourseAuthor, CourseAuthorshipEnum +from src import db +from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum from src.db.users import PublicUser, AnonymousUser from src.db.courses import Course, CourseCreate, CourseRead, CourseUpdate from src.security.rbac.rbac import ( @@ -60,7 +62,7 @@ async def create_course( # Complete course object course.org_id = course.org_id - course.course_uuid = str(uuid4()) + course.course_uuid = str(f"course_{uuid4()}") course.creation_date = str(datetime.now()) course.update_date = str(datetime.now()) @@ -76,18 +78,18 @@ async def create_course( db_session.refresh(course) # Make the user the creator of the course - course_author = CourseAuthor( - course_id=course.id if course.id else 0, + resource_author = ResourceAuthor( + resource_uuid=course.course_uuid, user_id=current_user.id, - authorship=CourseAuthorshipEnum.CREATOR, + authorship=ResourceAuthorshipEnum.CREATOR, creation_date=str(datetime.now()), update_date=str(datetime.now()), ) # Insert course author - db_session.add(course_author) + db_session.add(resource_author) db_session.commit() - db_session.refresh(course_author) + db_session.refresh(resource_author) return CourseRead.from_orm(course) @@ -241,26 +243,23 @@ async def verify_rights( course_id: str, current_user: PublicUser | AnonymousUser, action: Literal["create", "read", "update", "delete"], + db_session: Session, ): if action == "read": if current_user.id == "anonymous": await authorization_verify_if_element_is_public( - request, course_id, str(current_user.id), action + request, course_id, str(current_user.id), action, db_session ) else: - users = request.app.db["users"] - user = await users.find_one({"user_id": str(current_user.id)}) await authorization_verify_based_on_roles_and_authorship( request, str(current_user.id), action, - user["roles"], course_id, + db_session, ) else: - users = request.app.db["users"] - user = await users.find_one({"user_id": str(current_user.id)}) await authorization_verify_if_user_is_anon(str(current_user.id)) @@ -268,8 +267,8 @@ async def verify_rights( request, str(current_user.id), action, - user["roles"], course_id, + db_session, ) diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index 54182f0c..40cfdccc 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -214,7 +214,10 @@ async def get_orgs_by_user( statement = ( select(Organization) .join(UserOrganization) - .where(Organization.id == UserOrganization.org_id) + .where( + Organization.id == UserOrganization.org_id, + UserOrganization.user_id == user_id, + ) ) result = db_session.exec(statement) diff --git a/apps/api/src/services/orgs/schemas/__init__.py b/apps/api/src/services/orgs/schemas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/services/orgs/schemas/orgs.py b/apps/api/src/services/orgs/schemas/orgs.py deleted file mode 100644 index 56531357..00000000 --- a/apps/api/src/services/orgs/schemas/orgs.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional -from pydantic import BaseModel - -#### Classes #################################################### - - -class Organization(BaseModel): - name: str - description: str - email: str - slug: str - logo: Optional[str] - default: Optional[bool] = False - - -class OrganizationInDB(Organization): - org_id: str - - -class PublicOrganization(Organization): - name: str - description: str - email: str - slug: str - org_id: str - - def __getitem__(self, item): - return getattr(self, item) diff --git a/apps/api/src/services/roles/roles.py b/apps/api/src/services/roles/roles.py index 3da8f089..d7ac88e8 100644 --- a/apps/api/src/services/roles/roles.py +++ b/apps/api/src/services/roles/roles.py @@ -1,7 +1,7 @@ from uuid import uuid4 from sqlmodel import Session, select +from src.db.users import PublicUser from src.db.roles import Role, RoleCreate, RoleUpdate -from src.services.users.schemas.users import PublicUser from fastapi import HTTPException, Request from datetime import datetime diff --git a/apps/api/src/services/roles/schemas/__init__.py b/apps/api/src/services/roles/schemas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/services/roles/schemas/roles.py b/apps/api/src/services/roles/schemas/roles.py deleted file mode 100644 index 18b0c34c..00000000 --- a/apps/api/src/services/roles/schemas/roles.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Literal -from pydantic import BaseModel - - -# Database Models - -class Permission(BaseModel): - action_create: bool - action_read: bool - action_update: bool - action_delete: bool - - def __getitem__(self, item): - return getattr(self, item) - - -class Elements(BaseModel): - courses: Permission - users: Permission - houses: Permission - collections: Permission - organizations: Permission - coursechapters: Permission - activities: Permission - - def __getitem__(self, item): - return getattr(self, item) - - -class Role(BaseModel): - name: str - description: str - elements : Elements - org_id: str | Literal["*"] - - -class RoleInDB(Role): - role_id: str - created_at: str - updated_at: str - diff --git a/apps/api/src/services/users/schemas/__init__.py b/apps/api/src/services/users/schemas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/services/users/schemas/users.py b/apps/api/src/services/users/schemas/users.py deleted file mode 100644 index 2dea0e97..00000000 --- a/apps/api/src/services/users/schemas/users.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Literal -from pydantic import BaseModel - - -class UserOrganization(BaseModel): - org_id: str - org_role: Literal['owner', 'editor', 'member'] - - def __getitem__(self, item): - return getattr(self, item) - -class UserRolesInOrganization(BaseModel): - org_id: str - role_id: str - - def __getitem__(self, item): - return getattr(self, item) - - - -class User(BaseModel): - username: str - email: str - full_name: str | None = None - avatar_url: str | None = None - bio: str | None = None - - - -class UserWithPassword(User): - password: str - - -class UserInDB(User): - user_id: str - password: str - verified: bool | None = False - disabled: bool | None = False - orgs: list[UserOrganization] = [] - roles: list[UserRolesInOrganization] = [] - creation_date: str - update_date: str - - def __getitem__(self, item): - return getattr(self, item) - - - - -class PublicUser(User): - user_id: str - orgs: list[UserOrganization] = [] - roles: list[UserRolesInOrganization] = [] - creation_date: str - update_date: str - -class AnonymousUser(BaseModel): - user_id: str = "anonymous" - username: str = "anonymous" - roles: list[UserRolesInOrganization] = [ - UserRolesInOrganization(org_id="anonymous", role_id="role_anonymous") - ] - - - -# Forms #################################################### - -class PasswordChangeForm(BaseModel): - old_password: str - new_password: str diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 55bbab6d..29979fab 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -4,6 +4,7 @@ from fastapi import HTTPException, Request, status from sqlmodel import Session, select from src.db.organizations import Organization from src.db.users import ( + PublicUser, User, UserCreate, UserRead, @@ -12,7 +13,6 @@ from src.db.users import ( ) from src.db.user_organizations import UserOrganization from src.security.security import security_hash_password, security_verify_password -from src.services.users.schemas.users import PublicUser async def create_user(