From da7fe1be53e1916b22899fb0f89013f8ff878753 Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 10:53:09 -0300 Subject: [PATCH 01/11] permissions values --- .../alembic/versions/a9f4cd2e4f57_.py | 36 +++++++++++++++++++ rest/database/models/enums.py | 2 ++ 2 files changed, 38 insertions(+) create mode 100644 rest/database/alembic/versions/a9f4cd2e4f57_.py diff --git a/rest/database/alembic/versions/a9f4cd2e4f57_.py b/rest/database/alembic/versions/a9f4cd2e4f57_.py new file mode 100644 index 00000000..f29705dd --- /dev/null +++ b/rest/database/alembic/versions/a9f4cd2e4f57_.py @@ -0,0 +1,36 @@ +""" +Update permission enum values + +Revision ID: a9f4cd2e4f57 +Revises: ab54cfed2bdc +Create Date: 2024-03-22 10:29:23.445775 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a9f4cd2e4f57' +down_revision = 'ab54cfed2bdc' +branch_labels = None +depends_on = None + + +from alembic import op + +def upgrade(): + op.execute("ALTER TABLE user_workspace_associative ALTER COLUMN permission DROP DEFAULT") + op.execute("CREATE TYPE permission_new AS ENUM ('owner', 'admin', 'write', 'read')") + op.execute("ALTER TABLE user_workspace_associative ALTER COLUMN permission TYPE permission_new USING permission::text::permission_new") + op.execute("DROP TYPE permission") + op.execute("ALTER TYPE permission_new RENAME TO permission") + op.execute("ALTER TABLE user_workspace_associative ALTER COLUMN permission SET DEFAULT 'owner'") + +def downgrade(): + op.execute("ALTER TABLE user_workspace_associative ALTER COLUMN permission DROP DEFAULT") + op.execute("CREATE TYPE permission_new AS ENUM ('owner', 'read', 'Config')") + op.execute("ALTER TABLE user_workspace_associative ALTER COLUMN permission TYPE permission_new USING permission::text::permission_new") + op.execute("DROP TYPE permission") + op.execute("ALTER TYPE permission_new RENAME TO permission") + op.execute("ALTER TABLE user_workspace_associative ALTER COLUMN permission SET DEFAULT 'owner'") \ No newline at end of file diff --git a/rest/database/models/enums.py b/rest/database/models/enums.py index ed4ad701..aa4a3d4d 100644 --- a/rest/database/models/enums.py +++ b/rest/database/models/enums.py @@ -11,6 +11,8 @@ class Config: class Permission(str, enum.Enum): owner = 'owner' + admin = 'admin' + write = 'write' read = 'read' class Config: From a1a8095903735d1f3c92160e84de6391ebad179f Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 12:43:12 -0300 Subject: [PATCH 02/11] permissions list --- .../workspaces/components/workspaceSettings/UsersCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/workspaces/components/workspaceSettings/UsersCard.tsx b/frontend/src/features/workspaces/components/workspaceSettings/UsersCard.tsx index fd2c33f8..cba41598 100644 --- a/frontend/src/features/workspaces/components/workspaceSettings/UsersCard.tsx +++ b/frontend/src/features/workspaces/components/workspaceSettings/UsersCard.tsx @@ -88,8 +88,9 @@ export const UsersCard: FC = () => { setPermission(e.target.value); }} > + Admin Read - Owner + Write From e8d9aa70271d2aee024028d7664c0a9f652438fa Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 12:43:34 -0300 Subject: [PATCH 03/11] start refactoring authorizers --- rest/auth/__init__.py | 2 + rest/auth/base_authorizer.py | 75 ++++++++++++++++++++++++++++++++++++ rest/auth/workspace_admin.py | 68 ++++++++++++++++++++++++++++++++ rest/auth/workspace_owner.py | 65 +++++++++++++++++++++++++++++++ rest/auth/workspace_read.py | 0 rest/auth/workspace_write.py | 0 6 files changed, 210 insertions(+) create mode 100644 rest/auth/__init__.py create mode 100644 rest/auth/base_authorizer.py create mode 100644 rest/auth/workspace_admin.py create mode 100644 rest/auth/workspace_owner.py create mode 100644 rest/auth/workspace_read.py create mode 100644 rest/auth/workspace_write.py diff --git a/rest/auth/__init__.py b/rest/auth/__init__.py new file mode 100644 index 00000000..c82f5fa2 --- /dev/null +++ b/rest/auth/__init__.py @@ -0,0 +1,2 @@ +from .workspace_owner import WorkspaceOwnerAuthorizer +from .workspace_admin import WorkspaceAdminAuthorizer \ No newline at end of file diff --git a/rest/auth/base_authorizer.py b/rest/auth/base_authorizer.py new file mode 100644 index 00000000..e37202f2 --- /dev/null +++ b/rest/auth/base_authorizer.py @@ -0,0 +1,75 @@ +from fastapi import HTTPException, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from datetime import datetime, timedelta +from passlib.context import CryptContext +import jwt +from schemas.errors.base import ForbiddenError, ResourceNotFoundError +from core.settings import settings +from schemas.context.auth_context import AuthorizationContextData, WorkspaceAuthorizerData +from repository.user_repository import UserRepository +from repository.workspace_repository import WorkspaceRepository +from repository.piece_repository_repository import PieceRepositoryRepository +from database.models.enums import Permission, UserWorkspaceStatus +import functools +from typing import Optional, Dict +from cryptography.fernet import Fernet +from math import floor + + +class BaseAuthorizer(): + security = HTTPBearer() + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + secret = settings.AUTH_SECRET_KEY + expire = settings.AUTH_ACCESS_TOKEN_EXPIRE_MINUTES + algorithm = settings.AUTH_ALGORITHM + user_repository = UserRepository() + workspace_repository = WorkspaceRepository() + piece_repository_repository = PieceRepositoryRepository() + github_token_fernet = Fernet(settings.GITHUB_TOKEN_SECRET_KEY) + + @classmethod + def get_password_hash(cls, password): + return cls.pwd_context.hash(password) + + @classmethod + def verify_password(cls, plain_password, hashed_password): + return cls.pwd_context.verify(plain_password, hashed_password) + + @classmethod + def encode_token(cls, user_id): + exp = datetime.utcnow() + timedelta(days=0, minutes=cls.expire) + current_date = datetime.utcnow() + expires_in = floor((exp - current_date).total_seconds()) + if expires_in >= 120: + expires_in = expires_in - 120 + + payload = { + 'exp': datetime.utcnow() + timedelta(days=0, minutes=cls.expire), + 'iat': datetime.utcnow(), + 'sub': user_id + } + return { + "token": jwt.encode( + payload, + settings.AUTH_SECRET_KEY, + algorithm=cls.algorithm + ), + "expires_in": expires_in + } + + @classmethod + def decode_token(cls, token): + try: + payload = jwt.decode(token, cls.secret, algorithms=[cls.algorithm]) + return payload['sub'] + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail='Signature has expired') + except jwt.InvalidTokenError as e: + raise HTTPException(status_code=401, detail='Invalid token') + + @classmethod + def auth_wrapper(cls, auth: HTTPAuthorizationCredentials = Security(security)): + user_id = cls.decode_token(auth.credentials) + return AuthorizationContextData( + user_id=user_id + ) diff --git a/rest/auth/workspace_admin.py b/rest/auth/workspace_admin.py new file mode 100644 index 00000000..da15eb7c --- /dev/null +++ b/rest/auth/workspace_admin.py @@ -0,0 +1,68 @@ +from fastapi import HTTPException, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from datetime import datetime, timedelta +from passlib.context import CryptContext +import jwt +from schemas.errors.base import ForbiddenError, ResourceNotFoundError +from core.settings import settings +from schemas.context.auth_context import AuthorizationContextData, WorkspaceAuthorizerData +from repository.user_repository import UserRepository +from repository.workspace_repository import WorkspaceRepository +from repository.piece_repository_repository import PieceRepositoryRepository +from database.models.enums import Permission, UserWorkspaceStatus +import functools +from typing import Optional, Dict +from cryptography.fernet import Fernet +from math import floor +from auth.base_authorizer import BaseAuthorizer + + + +class WorkspaceAdminAuthorizer(BaseAuthorizer): + security = HTTPBearer() + def __init__(self): + super().__init__() + + def authorize( + self, + workspace_id: Optional[int], + auth: HTTPAuthorizationCredentials = Security(security), + ): + """ + Authorizer admin level or more + """ + auth_context = self.auth_wrapper(auth) + if not workspace_id: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + workspace_associative_data = self.workspace_repository.find_by_id_and_user_id( + id=workspace_id, + user_id=auth_context.user_id + ) + if not workspace_associative_data: + raise HTTPException(status_code=ResourceNotFoundError().status_code, detail=ResourceNotFoundError().message) + + if workspace_associative_data and not workspace_associative_data.permission: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + if workspace_associative_data and workspace_associative_data.status != UserWorkspaceStatus.accepted.value: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + if workspace_associative_data.permission.value not in [Permission.admin.value, Permission.owner.value]: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + decoded_github_token = None if not workspace_associative_data.github_access_token else self.github_token_fernet.decrypt(workspace_associative_data.github_access_token.encode('utf-8')).decode('utf-8') + auth_context.workspace = WorkspaceAuthorizerData( + id=workspace_associative_data.workspace_id, + name=workspace_associative_data.name, + github_access_token=decoded_github_token, + user_permission=workspace_associative_data.permission + ) + return auth_context + + def authorize_with_body( + self, + body: Optional[Dict] = None, + auth: HTTPAuthorizationCredentials = Security(security), + ): + workspace_id = body.get('workspace_id') + return self.authorize(workspace_id=workspace_id, auth=auth) \ No newline at end of file diff --git a/rest/auth/workspace_owner.py b/rest/auth/workspace_owner.py new file mode 100644 index 00000000..18085301 --- /dev/null +++ b/rest/auth/workspace_owner.py @@ -0,0 +1,65 @@ +from fastapi import HTTPException, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from datetime import datetime, timedelta +from passlib.context import CryptContext +import jwt +from schemas.errors.base import ForbiddenError, ResourceNotFoundError +from core.settings import settings +from schemas.context.auth_context import AuthorizationContextData, WorkspaceAuthorizerData +from repository.user_repository import UserRepository +from repository.workspace_repository import WorkspaceRepository +from repository.piece_repository_repository import PieceRepositoryRepository +from database.models.enums import Permission, UserWorkspaceStatus +import functools +from typing import Optional, Dict +from cryptography.fernet import Fernet +from math import floor +from auth.base_authorizer import BaseAuthorizer + + + +class WorkspaceOwnerAuthorizer(BaseAuthorizer): + security = HTTPBearer() + def __init__(self): + super().__init__() + + def authorize( + self, + workspace_id: Optional[int], + auth: HTTPAuthorizationCredentials = Security(security), + ): + auth_context = self.auth_wrapper(auth) + if not workspace_id: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + workspace_associative_data = self.workspace_repository.find_by_id_and_user_id( + id=workspace_id, + user_id=auth_context.user_id + ) + if not workspace_associative_data: + raise HTTPException(status_code=ResourceNotFoundError().status_code, detail=ResourceNotFoundError().message) + + if workspace_associative_data and not workspace_associative_data.permission: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + if workspace_associative_data and workspace_associative_data.status != UserWorkspaceStatus.accepted.value: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + if workspace_associative_data.permission != Permission.owner.value: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + decoded_github_token = None if not workspace_associative_data.github_access_token else self.github_token_fernet.decrypt(workspace_associative_data.github_access_token.encode('utf-8')).decode('utf-8') + auth_context.workspace = WorkspaceAuthorizerData( + id=workspace_associative_data.workspace_id, + name=workspace_associative_data.name, + github_access_token=decoded_github_token, + user_permission=workspace_associative_data.permission + ) + return auth_context + + def authorize_with_body( + self, + body: Optional[Dict] = None, + auth: HTTPAuthorizationCredentials = Security(security), + ): + workspace_id = body.get('workspace_id') + return self.authorize(workspace_id=workspace_id, auth=auth) \ No newline at end of file diff --git a/rest/auth/workspace_read.py b/rest/auth/workspace_read.py new file mode 100644 index 00000000..e69de29b diff --git a/rest/auth/workspace_write.py b/rest/auth/workspace_write.py new file mode 100644 index 00000000..e69de29b From ed53bbb1489318e66c389d6d8691ee599c719c16 Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 12:44:34 -0300 Subject: [PATCH 04/11] start refactoring workspace router services and db --- rest/database/models/enums.py | 5 ++ rest/repository/workflow_repository.py | 22 ++--- rest/repository/workspace_repository.py | 110 ++++++++++++++---------- rest/routers/workspace_router.py | 25 +++--- rest/schemas/requests/workspace.py | 6 +- rest/services/workspace_service.py | 30 ++++--- 6 files changed, 118 insertions(+), 80 deletions(-) diff --git a/rest/database/models/enums.py b/rest/database/models/enums.py index aa4a3d4d..e5f91de5 100644 --- a/rest/database/models/enums.py +++ b/rest/database/models/enums.py @@ -19,6 +19,11 @@ class Config: use_enum_values = True +class MembersPermissions(str, enum.Enum): + admin = 'admin' + write = 'write' + read = 'read' + class UserWorkspaceStatus(str, enum.Enum): pending = 'pending' accepted = 'accepted' diff --git a/rest/repository/workflow_repository.py b/rest/repository/workflow_repository.py index 1d766977..4d7ccabf 100644 --- a/rest/repository/workflow_repository.py +++ b/rest/repository/workflow_repository.py @@ -21,12 +21,12 @@ def find_by_id(self, id: int): return result def find_by_workspace_id( - self, - workspace_id: int, - page: int = 0, - page_size: int = 100, - filters: dict = None, - paginate=True, + self, + workspace_id: int, + page: int = 0, + page_size: int = 100, + filters: dict = None, + paginate=True, count=True, descending=False ): @@ -39,7 +39,7 @@ def find_by_workspace_id( if filters: query = query.magic_filters(filters) - + if paginate: results = query.paginate(page, page_size) else: @@ -65,7 +65,7 @@ def create(self, workflow: Workflow): session.refresh(workflow) session.expunge(workflow) return workflow - + def get_workflows_summary(self): with session_scope() as session: @@ -81,7 +81,7 @@ def delete(self, id): session.flush() session.expunge_all() return result - + def delete_by_workspace_id(self, workspace_id: int): with session_scope() as session: result = session.query(Workflow).filter(Workflow.workspace_id==workspace_id).delete(synchronize_session=False) @@ -90,7 +90,7 @@ def delete_by_workspace_id(self, workspace_id: int): return result def create_workflow_piece_repositories_associations( - self, + self, workflow_piece_repository_associative: list[WorkflowPieceRepositoryAssociative] ): with session_scope() as session: @@ -135,4 +135,4 @@ def update(self, workflow: Workflow): saved_workflow.last_changed_by = workflow.last_changed_by session.flush() session.expunge(saved_workflow) - return workflow + return workflow \ No newline at end of file diff --git a/rest/repository/workspace_repository.py b/rest/repository/workspace_repository.py index ce496ca4..47ceb9f6 100644 --- a/rest/repository/workspace_repository.py +++ b/rest/repository/workspace_repository.py @@ -1,5 +1,5 @@ from database.interface import session_scope -from database.models import Workspace, UserWorkspaceAssociative, User +from database.models import Workspace, UserWorkspaceAssociative, User, Workflow from database.models.enums import UserWorkspaceStatus, Permission from typing import Tuple, List from sqlalchemy import and_, func @@ -44,7 +44,7 @@ def find_by_id(self, id: int) -> Workspace: if result: session.expunge_all() return result - + def find_workspace_users(self, workspace_id: int, page: int, page_size: int): """ SELECT user_workspace_associative.*, "user".*, total_count.count AS count @@ -75,7 +75,7 @@ def find_workspace_users(self, workspace_id: int, page: int, page_size: int): session.expunge_all() return results - + def find_pending_workspace_invite(self, user_id: int, workspace_id: int): with session_scope() as session: result = session.query(Workspace, UserWorkspaceAssociative)\ @@ -85,7 +85,7 @@ def find_pending_workspace_invite(self, user_id: int, workspace_id: int): if result: session.expunge_all() return result - + def update_user_workspace_associative_by_ids(self, associative: UserWorkspaceAssociative): with session_scope() as session: saved_associative = session.query(UserWorkspaceAssociative)\ @@ -105,14 +105,14 @@ def find_by_id_and_user(self, id: int, user_id: int) -> Workspace: """ SELECT workspace.id, workspace.name, workspace.github_access_token, user_workspace_associative.permission FROM workspace - INNER JOIN user_workspace_associative + INNER JOIN user_workspace_associative ON user_workspace_associative.workspace_id = workspace.id and user_id=1 WHERE workspace_id=9; """ query = session.query( - Workspace.id.label('id'), - Workspace.name, - Workspace.github_access_token, + Workspace.id.label('id'), + Workspace.name, + Workspace.github_access_token, UserWorkspaceAssociative.permission.label('permission'), UserWorkspaceAssociative.status.label('status') )\ @@ -157,52 +157,74 @@ def remove_user_from_workspaces(self, user_id: int, workspaces_ids: List[int]): session.query(UserWorkspaceAssociative)\ .filter(and_(UserWorkspaceAssociative.user_id==user_id, UserWorkspaceAssociative.workspace_id.in_(workspaces_ids)))\ .delete(synchronize_session=False) - + def find_user_workspaces_members_owners_count(self, user_id: int, workspaces_ids: List[int]) -> List: """ - SELECT * from user_workspace_associative as t1 + SELECT t1.*, t2.members_count, t3.owners_count, COUNT(wf.id) AS total_workflows + FROM user_workspace_associative AS t1 INNER JOIN ( - SELECT workspace_id, COUNT(*) as user_count from user_workspace_associative - WHERE user_workspace_associative.workspace_id in (1) GROUP BY workspace_id - ) as t2 - INNER JOIN ( - SELECT workspace_id, COUNT(*) as owners_count from user_workspace_associative - WHERE user_workspace_associative.workspace_id in (ids) and user_workspace_associative.permission = 'owner' - GROUP BY workspace_id - ) as t3 - ON t2.workspace_id=t2.workspace_id - ON t1.workspace_id=t2.workspace_id - WHERE t1.user_id=2; + SELECT workspace_id, COUNT(*) AS members_count + FROM user_workspace_associative + WHERE user_workspace_associative.workspace_id IN (2) + GROUP BY workspace_id + ) AS t2 ON t1.workspace_id = t2.workspace_id + INNER JOIN ( + SELECT workspace_id, COUNT(*) AS owners_count + FROM user_workspace_associative + WHERE user_workspace_associative.workspace_id IN (2) + AND user_workspace_associative.permission = 'owner' + GROUP BY workspace_id + ) AS t3 ON t1.workspace_id = t3.workspace_id + LEFT JOIN workflow AS wf ON t1.workspace_id = wf.workspace_id + WHERE t1.user_id = 2 + AND wf.workspace_id = 2 + GROUP BY t1.user_id, t1.workspace_id, t2.members_count, t3.owners_count; """ with session_scope() as session: - # create a subquery - subquery_owners = session.query( - UserWorkspaceAssociative.workspace_id, - func.count(UserWorkspaceAssociative.workspace_id).label('owners_count') - ).filter(UserWorkspaceAssociative.workspace_id.in_(workspaces_ids))\ - .filter(UserWorkspaceAssociative.permission == Permission.owner.value)\ - .group_by(UserWorkspaceAssociative.workspace_id).subquery() + subquery_users = ( + session.query( + UserWorkspaceAssociative.workspace_id, + func.count('*').label('members_count') + ) + .filter(UserWorkspaceAssociative.workspace_id.in_(workspaces_ids)) + .group_by(UserWorkspaceAssociative.workspace_id) + .subquery() + ) - subquery = ( + # Subquery for counting owners in each workspace + subquery_owners = ( session.query( UserWorkspaceAssociative.workspace_id, - func.count(UserWorkspaceAssociative.workspace_id).label('members_count'), - subquery_owners.c.owners_count + func.count('*').label('owners_count') ) .filter(UserWorkspaceAssociative.workspace_id.in_(workspaces_ids)) - .group_by(UserWorkspaceAssociative.workspace_id, subquery_owners.c.owners_count) - .join(subquery_owners, UserWorkspaceAssociative.workspace_id == subquery_owners.c.workspace_id) + .filter(UserWorkspaceAssociative.permission == 'owner') + .group_by(UserWorkspaceAssociative.workspace_id) .subquery() ) - query = session.query( - UserWorkspaceAssociative.user_id, - UserWorkspaceAssociative.workspace_id, - UserWorkspaceAssociative.permission, - subquery.c.members_count, - subquery.c.owners_count - ).join(subquery, UserWorkspaceAssociative.workspace_id == subquery.c.workspace_id)\ + # Main query + query = ( + session.query( + UserWorkspaceAssociative.user_id, + UserWorkspaceAssociative.workspace_id, + UserWorkspaceAssociative.permission, + UserWorkspaceAssociative.status, + subquery_users.c.members_count, + subquery_owners.c.owners_count, + func.count(Workflow.id).label('total_workflows') + ) + .join(subquery_users, UserWorkspaceAssociative.workspace_id == subquery_users.c.workspace_id) + .join(subquery_owners, UserWorkspaceAssociative.workspace_id == subquery_owners.c.workspace_id) + .outerjoin(Workflow, UserWorkspaceAssociative.workspace_id == Workflow.workspace_id) .filter(UserWorkspaceAssociative.user_id == user_id) + .group_by( + UserWorkspaceAssociative.user_id, + UserWorkspaceAssociative.workspace_id, + subquery_users.c.members_count, + subquery_owners.c.owners_count + ) + ) result = query.all() if result: session.expunge_all() @@ -213,7 +235,7 @@ def find_user_workspaces_members_count(self, user_id: int, workspaces_ids: List[ SQL Query: SELECT * from user_workspace_associative as t1 INNER JOIN ( - SELECT workspace_id, COUNT(*) as user_count from user_workspace_associative + SELECT workspace_id, COUNT(*) as members_count from user_workspace_associative WHERE user_workspace_associative.workspace_id in (ids) GROUP BY workspace_id ) as t2 ON t1.workspace_id=t2.workspace_id @@ -222,14 +244,14 @@ def find_user_workspaces_members_count(self, user_id: int, workspaces_ids: List[ with session_scope() as session: # create a subquery subquery = session.query( - UserWorkspaceAssociative.workspace_id, + UserWorkspaceAssociative.workspace_id, func.count(UserWorkspaceAssociative.workspace_id).label('members_count') ).filter(UserWorkspaceAssociative.workspace_id.in_(workspaces_ids))\ .group_by(UserWorkspaceAssociative.workspace_id).subquery() query = session.query( UserWorkspaceAssociative.user_id, - UserWorkspaceAssociative.workspace_id, + UserWorkspaceAssociative.workspace_id, UserWorkspaceAssociative.permission, subquery.c.members_count ).join(subquery, UserWorkspaceAssociative.workspace_id == subquery.c.workspace_id)\ @@ -238,7 +260,7 @@ def find_user_workspaces_members_count(self, user_id: int, workspaces_ids: List[ if result: session.expunge_all() return result - + def find_by_name_and_user_id(self, name: str, user_id: int): with session_scope() as session: result = session.query(Workspace)\ diff --git a/rest/routers/workspace_router.py b/rest/routers/workspace_router.py index 3137e82d..ab4e2c61 100644 --- a/rest/routers/workspace_router.py +++ b/rest/routers/workspace_router.py @@ -5,20 +5,23 @@ from schemas.context.auth_context import AuthorizationContextData from schemas.requests.workspace import CreateWorkspaceRequest, AssignWorkspaceRequest, PatchWorkspaceRequest from schemas.responses.workspace import ( - CreateWorkspaceResponse, - ListUserWorkspacesResponse, - GetWorkspaceResponse, - PatchWorkspaceResponse, + CreateWorkspaceResponse, + ListUserWorkspacesResponse, + GetWorkspaceResponse, + PatchWorkspaceResponse, ListWorkspaceUsersResponse ) from schemas.exceptions.base import BaseException, ConflictException, ResourceNotFoundException, ForbiddenException, UnauthorizedException from schemas.errors.base import ConflictError, ForbiddenError, SomethingWrongError, ResourceNotFoundError, UnauthorizedError from database.models.enums import UserWorkspaceStatus from typing import List +from auth import WorkspaceOwnerAuthorizer, WorkspaceAdminAuthorizer router = APIRouter(prefix="/workspaces") auth_service = AuthService() +workspace_owner_authorizer = WorkspaceOwnerAuthorizer() +workspace_admin_authorizer = WorkspaceAdminAuthorizer() workspace_service = WorkspaceService() @@ -107,7 +110,7 @@ def get_workspace(workspace_id: int, auth_context: AuthorizationContextData = De def add_user_to_workspace( workspace_id: int, body: AssignWorkspaceRequest, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_owner_access_authorizer) + auth_context: AuthorizationContextData = Depends(workspace_owner_authorizer.authorize) ): """Assign workspace to user with permission""" try: @@ -117,7 +120,7 @@ def add_user_to_workspace( ) except (BaseException, ResourceNotFoundException, ConflictException, ForbiddenException) as e: raise HTTPException(status_code=e.status_code, detail=e.message) - + @router.post( '/{workspace_id}/invites/accept', @@ -189,7 +192,7 @@ def reject_workspace_invite( status.HTTP_403_FORBIDDEN: {'model': ForbiddenError}, status.HTTP_409_CONFLICT: {'model': ConflictError} }, - dependencies=[Depends(auth_service.workspace_owner_access_authorizer)] + dependencies=[Depends(workspace_owner_authorizer.authorize)] ) async def delete_workspace( workspace_id: int, @@ -201,7 +204,7 @@ async def delete_workspace( return response except (BaseException, ResourceNotFoundException, ConflictException, ForbiddenException) as e: raise HTTPException(status_code=e.status_code, detail=e.message) - + @router.patch( path="/{workspace_id}", @@ -216,7 +219,7 @@ async def delete_workspace( def patch_workspace( workspace_id: int, body: PatchWorkspaceRequest, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_owner_access_authorizer) + auth_context: AuthorizationContextData = Depends(workspace_owner_authorizer.authorize) ): try: response = workspace_service.patch_workspace( @@ -227,7 +230,7 @@ def patch_workspace( return response except (BaseException, ResourceNotFoundException, ForbiddenException) as e: raise HTTPException(status_code=e.status_code, detail=e.message) - + @router.delete( path="/{workspace_id}/users/{user_id}", @@ -242,7 +245,7 @@ def patch_workspace( async def remove_user_from_workspace( workspace_id: int, user_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(workspace_admin_authorizer.authorize) ): try: await workspace_service.remove_user_from_workspace( diff --git a/rest/schemas/requests/workspace.py b/rest/schemas/requests/workspace.py index b59fcd2b..94644251 100644 --- a/rest/schemas/requests/workspace.py +++ b/rest/schemas/requests/workspace.py @@ -1,14 +1,14 @@ from pydantic import BaseModel, Field, SecretStr from typing import Optional -from database.models.enums import Permission +from database.models.enums import MembersPermissions class CreateWorkspaceRequest(BaseModel): name: str = Field(..., description="Name of the workspace") - + class PatchWorkspaceRequest(BaseModel): github_access_token: Optional[str] = Field(description='Secret value', default=None) class AssignWorkspaceRequest(BaseModel): - permission: Permission + permission: MembersPermissions user_email: str = Field(..., description="Email of the user to be assigned to the workspace") \ No newline at end of file diff --git a/rest/services/workspace_service.py b/rest/services/workspace_service.py index 1c3fb1a9..4b9a5eed 100644 --- a/rest/services/workspace_service.py +++ b/rest/services/workspace_service.py @@ -33,7 +33,7 @@ def __init__(self) -> None: self.logger = get_configured_logger(self.__class__.__name__) self.workflow_service = WorkflowService() self.github_token_fernet = Fernet(settings.GITHUB_TOKEN_SECRET_KEY) - + def create_workspace( self, @@ -94,7 +94,7 @@ def create_workspace( ) thread.start() threads.append(thread) - + for thread in threads: thread.join() @@ -189,6 +189,9 @@ def add_user_to_workspace( if not user: raise ResourceNotFoundException('User email not found.') + if body.permission.value == Permission.owner.value: + raise ConflictException('Cannot assign owner permission to user.') + for workspace_assoc in user.workspaces: if workspace_assoc.workspace.id == workspace_id and workspace_assoc.status == UserWorkspaceStatus.pending.value: raise ConflictException('User already invited to this workspace.') @@ -255,31 +258,36 @@ def handle_invite_action(self, workspace_id: int, auth_context: AuthorizationCon return response async def remove_user_from_workspace(self, workspace_id: int, user_id: int, auth_context: AuthorizationContextData): - # Can't remove other users if not owner - if auth_context.user_id != user_id and auth_context.workspace.user_permission != Permission.owner.value: - raise ForbiddenException() workspace_infos = self.workspace_repository.find_user_workspaces_members_owners_count( user_id=user_id, workspaces_ids=[workspace_id] ) + if not workspace_infos: raise ResourceNotFoundException('User not found in workspace.') workspace_info = workspace_infos[0] + workflows_count = workspace_info.total_workflows + + if workspace_info.permission == Permission.owner.value: + raise ForbiddenException('Cannot remove owner from workspace.') + + if workspace_info.members_count == 1 and workflows_count > 0: + raise ForbiddenException('Cannot remove last user from workspace with workflows.') + # If the workspace has only one member (the user) delete the workspace (even the user not being the owner). if workspace_info.members_count == 1: await self.delete_workspace(workspace_id=workspace_id) return # If the user is owner and the workspace has only one owner (the user) but has more than one member, delete the workspace. - if workspace_info.owners_count == 1 and auth_context.user_id == user_id and auth_context.workspace.user_permission == Permission.owner.value: - await self.delete_workspace(workspace_id=workspace_id) - + # DEPRECATED now we cant remove owners from workspace, owners can only delete workspaces (would be the same action as this one) + # if workspace_info.owners_count == 1 and auth_context.user_id == user_id and auth_context.workspace.user_permission == Permission.owner.value: + # await self.delete_workspace(workspace_id=workspace_id) - # If the user is owner but is deleting another user, just remove the user from workspace - # If user is read only and workspace has more than one member, just remove the user from workspace - # Or if workspace has more than one owner just remove the user from workspace + # If the user is admin/owner but is deleting another user, just remove the user from workspace + # If user is read/write and workspace has more than one member, just remove the user from workspace self.workspace_repository.remove_user_from_workspaces( workspaces_ids=[workspace_id], user_id=user_id From b071caad0099bf07540e552f0ed797ef688beeea Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 15:23:07 -0300 Subject: [PATCH 05/11] add simple authorizer with permission level --- rest/auth/__init__.py | 2 - ...space_admin.py => workspace_authorizer.py} | 19 ++++-- rest/auth/workspace_owner.py | 65 ------------------- rest/auth/workspace_read.py | 0 rest/auth/workspace_write.py | 0 rest/routers/workspace_router.py | 8 +-- 6 files changed, 17 insertions(+), 77 deletions(-) rename rest/auth/{workspace_admin.py => workspace_authorizer.py} (77%) delete mode 100644 rest/auth/workspace_owner.py delete mode 100644 rest/auth/workspace_read.py delete mode 100644 rest/auth/workspace_write.py diff --git a/rest/auth/__init__.py b/rest/auth/__init__.py index c82f5fa2..e69de29b 100644 --- a/rest/auth/__init__.py +++ b/rest/auth/__init__.py @@ -1,2 +0,0 @@ -from .workspace_owner import WorkspaceOwnerAuthorizer -from .workspace_admin import WorkspaceAdminAuthorizer \ No newline at end of file diff --git a/rest/auth/workspace_admin.py b/rest/auth/workspace_authorizer.py similarity index 77% rename from rest/auth/workspace_admin.py rename to rest/auth/workspace_authorizer.py index da15eb7c..98ba9b2d 100644 --- a/rest/auth/workspace_admin.py +++ b/rest/auth/workspace_authorizer.py @@ -18,19 +18,26 @@ -class WorkspaceAdminAuthorizer(BaseAuthorizer): +class WorkspaceAuthorizer(BaseAuthorizer): security = HTTPBearer() - def __init__(self): + # Permission level map is used to determine what permission can access each level + # Ex: owners can access everything, admin can access everything except owner + permission_level_map = { + Permission.owner.value: [Permission.owner], + Permission.admin.value: [Permission.admin, Permission.owner], + Permission.write.value: [Permission.write, Permission.admin, Permission.owner], + Permission.read.value: [Permission.read, Permission.write, Permission.admin, Permission.owner] + } + def __init__(self, permission_level: Permission = Permission.owner.value): super().__init__() + self.permission = permission_level + self.permission_level = self.permission_level_map[permission_level] def authorize( self, workspace_id: Optional[int], auth: HTTPAuthorizationCredentials = Security(security), ): - """ - Authorizer admin level or more - """ auth_context = self.auth_wrapper(auth) if not workspace_id: raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) @@ -47,7 +54,7 @@ def authorize( if workspace_associative_data and workspace_associative_data.status != UserWorkspaceStatus.accepted.value: raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) - if workspace_associative_data.permission.value not in [Permission.admin.value, Permission.owner.value]: + if workspace_associative_data.permission not in self.permission_level: raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) decoded_github_token = None if not workspace_associative_data.github_access_token else self.github_token_fernet.decrypt(workspace_associative_data.github_access_token.encode('utf-8')).decode('utf-8') diff --git a/rest/auth/workspace_owner.py b/rest/auth/workspace_owner.py deleted file mode 100644 index 18085301..00000000 --- a/rest/auth/workspace_owner.py +++ /dev/null @@ -1,65 +0,0 @@ -from fastapi import HTTPException, Security -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from datetime import datetime, timedelta -from passlib.context import CryptContext -import jwt -from schemas.errors.base import ForbiddenError, ResourceNotFoundError -from core.settings import settings -from schemas.context.auth_context import AuthorizationContextData, WorkspaceAuthorizerData -from repository.user_repository import UserRepository -from repository.workspace_repository import WorkspaceRepository -from repository.piece_repository_repository import PieceRepositoryRepository -from database.models.enums import Permission, UserWorkspaceStatus -import functools -from typing import Optional, Dict -from cryptography.fernet import Fernet -from math import floor -from auth.base_authorizer import BaseAuthorizer - - - -class WorkspaceOwnerAuthorizer(BaseAuthorizer): - security = HTTPBearer() - def __init__(self): - super().__init__() - - def authorize( - self, - workspace_id: Optional[int], - auth: HTTPAuthorizationCredentials = Security(security), - ): - auth_context = self.auth_wrapper(auth) - if not workspace_id: - raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) - workspace_associative_data = self.workspace_repository.find_by_id_and_user_id( - id=workspace_id, - user_id=auth_context.user_id - ) - if not workspace_associative_data: - raise HTTPException(status_code=ResourceNotFoundError().status_code, detail=ResourceNotFoundError().message) - - if workspace_associative_data and not workspace_associative_data.permission: - raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) - - if workspace_associative_data and workspace_associative_data.status != UserWorkspaceStatus.accepted.value: - raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) - - if workspace_associative_data.permission != Permission.owner.value: - raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) - - decoded_github_token = None if not workspace_associative_data.github_access_token else self.github_token_fernet.decrypt(workspace_associative_data.github_access_token.encode('utf-8')).decode('utf-8') - auth_context.workspace = WorkspaceAuthorizerData( - id=workspace_associative_data.workspace_id, - name=workspace_associative_data.name, - github_access_token=decoded_github_token, - user_permission=workspace_associative_data.permission - ) - return auth_context - - def authorize_with_body( - self, - body: Optional[Dict] = None, - auth: HTTPAuthorizationCredentials = Security(security), - ): - workspace_id = body.get('workspace_id') - return self.authorize(workspace_id=workspace_id, auth=auth) \ No newline at end of file diff --git a/rest/auth/workspace_read.py b/rest/auth/workspace_read.py deleted file mode 100644 index e69de29b..00000000 diff --git a/rest/auth/workspace_write.py b/rest/auth/workspace_write.py deleted file mode 100644 index e69de29b..00000000 diff --git a/rest/routers/workspace_router.py b/rest/routers/workspace_router.py index ab4e2c61..e56ca300 100644 --- a/rest/routers/workspace_router.py +++ b/rest/routers/workspace_router.py @@ -15,13 +15,13 @@ from schemas.errors.base import ConflictError, ForbiddenError, SomethingWrongError, ResourceNotFoundError, UnauthorizedError from database.models.enums import UserWorkspaceStatus from typing import List -from auth import WorkspaceOwnerAuthorizer, WorkspaceAdminAuthorizer - +from auth.workspace_authorizer import WorkspaceAuthorizer router = APIRouter(prefix="/workspaces") auth_service = AuthService() -workspace_owner_authorizer = WorkspaceOwnerAuthorizer() -workspace_admin_authorizer = WorkspaceAdminAuthorizer() + +workspace_owner_authorizer = WorkspaceAuthorizer(permission_level='owner') +workspace_admin_authorizer = WorkspaceAuthorizer(permission_level='admin') workspace_service = WorkspaceService() From 0adcbea5becc0f8dca2fbced00d33cef78d4c19e Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 15:32:54 -0300 Subject: [PATCH 06/11] workspace access authorizer --- rest/routers/workspace_router.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/rest/routers/workspace_router.py b/rest/routers/workspace_router.py index e56ca300..8ab900c0 100644 --- a/rest/routers/workspace_router.py +++ b/rest/routers/workspace_router.py @@ -1,6 +1,4 @@ from fastapi import APIRouter, HTTPException, status, Depends, Response -from services.auth_service import AuthService - from services.workspace_service import WorkspaceService from schemas.context.auth_context import AuthorizationContextData from schemas.requests.workspace import CreateWorkspaceRequest, AssignWorkspaceRequest, PatchWorkspaceRequest @@ -16,12 +14,14 @@ from database.models.enums import UserWorkspaceStatus from typing import List from auth.workspace_authorizer import WorkspaceAuthorizer +from auth.base_authorizer import BaseAuthorizer router = APIRouter(prefix="/workspaces") -auth_service = AuthService() +base_authorizer = BaseAuthorizer() workspace_owner_authorizer = WorkspaceAuthorizer(permission_level='owner') workspace_admin_authorizer = WorkspaceAuthorizer(permission_level='admin') +workspace_read_authorizer = WorkspaceAuthorizer(permission_level='read') workspace_service = WorkspaceService() @@ -39,7 +39,7 @@ ) def create_workspace( body: CreateWorkspaceRequest, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(base_authorizer.auth_wrapper) ) -> CreateWorkspaceResponse: """Create workspace""" try: @@ -64,7 +64,7 @@ def create_workspace( def list_user_workspaces( page: int = 0, page_size: int = 10, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(base_authorizer.auth_wrapper) ) -> List[ListUserWorkspacesResponse]: """List user workspaces summary""" try: @@ -87,7 +87,7 @@ def list_user_workspaces( status.HTTP_404_NOT_FOUND: {'model': ResourceNotFoundError} }, ) -def get_workspace(workspace_id: int, auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer)) -> GetWorkspaceResponse: +def get_workspace(workspace_id: int, auth_context: AuthorizationContextData = Depends(workspace_read_authorizer.authorize)) -> GetWorkspaceResponse: """Get specific workspace data. Includes users, workflows and repositories""" try: response = workspace_service.get_workspace_data(workspace_id=workspace_id, auth_context=auth_context) @@ -110,7 +110,7 @@ def get_workspace(workspace_id: int, auth_context: AuthorizationContextData = De def add_user_to_workspace( workspace_id: int, body: AssignWorkspaceRequest, - auth_context: AuthorizationContextData = Depends(workspace_owner_authorizer.authorize) + auth_context: AuthorizationContextData = Depends(workspace_admin_authorizer.authorize) ): """Assign workspace to user with permission""" try: @@ -135,7 +135,7 @@ def add_user_to_workspace( ) def accept_workspace_invite( workspace_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(base_authorizer.auth_wrapper) ) -> GetWorkspaceResponse: """ Accept workspace invite. @@ -164,7 +164,7 @@ def accept_workspace_invite( ) def reject_workspace_invite( workspace_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(base_authorizer.auth_wrapper) ) -> GetWorkspaceResponse: """ Reject workspace invite. @@ -270,7 +270,7 @@ def list_workspace_users( workspace_id: int, page: int = 0, page_size: int = 10, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(workspace_read_authorizer.authorize) ) -> ListWorkspaceUsersResponse: try: return workspace_service.list_workspace_users( From 00592f029a2368caff5973c8d38b32fc6883e8a2 Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 15:53:34 -0300 Subject: [PATCH 07/11] add permission authorizer to piece repository router --- ...authorizer.py => permission_authorizer.py} | 48 ++++++++++++++----- rest/routers/piece_repository_router.py | 23 +++++---- rest/routers/workspace_router.py | 31 ++++++------ 3 files changed, 64 insertions(+), 38 deletions(-) rename rest/auth/{workspace_authorizer.py => permission_authorizer.py} (64%) diff --git a/rest/auth/workspace_authorizer.py b/rest/auth/permission_authorizer.py similarity index 64% rename from rest/auth/workspace_authorizer.py rename to rest/auth/permission_authorizer.py index 98ba9b2d..03f78ab4 100644 --- a/rest/auth/workspace_authorizer.py +++ b/rest/auth/permission_authorizer.py @@ -1,24 +1,15 @@ from fastapi import HTTPException, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from datetime import datetime, timedelta -from passlib.context import CryptContext import jwt from schemas.errors.base import ForbiddenError, ResourceNotFoundError -from core.settings import settings from schemas.context.auth_context import AuthorizationContextData, WorkspaceAuthorizerData -from repository.user_repository import UserRepository -from repository.workspace_repository import WorkspaceRepository -from repository.piece_repository_repository import PieceRepositoryRepository from database.models.enums import Permission, UserWorkspaceStatus -import functools from typing import Optional, Dict -from cryptography.fernet import Fernet -from math import floor from auth.base_authorizer import BaseAuthorizer -class WorkspaceAuthorizer(BaseAuthorizer): +class Authorizer(BaseAuthorizer): security = HTTPBearer() # Permission level map is used to determine what permission can access each level # Ex: owners can access everything, admin can access everything except owner @@ -28,7 +19,7 @@ class WorkspaceAuthorizer(BaseAuthorizer): Permission.write.value: [Permission.write, Permission.admin, Permission.owner], Permission.read.value: [Permission.read, Permission.write, Permission.admin, Permission.owner] } - def __init__(self, permission_level: Permission = Permission.owner.value): + def __init__(self, permission_level: Permission = Permission.read.value): super().__init__() self.permission = permission_level self.permission_level = self.permission_level_map[permission_level] @@ -72,4 +63,37 @@ def authorize_with_body( auth: HTTPAuthorizationCredentials = Security(security), ): workspace_id = body.get('workspace_id') - return self.authorize(workspace_id=workspace_id, auth=auth) \ No newline at end of file + return self.authorize(workspace_id=workspace_id, auth=auth) + + def authorize_piece_repository( + self, + piece_repository_id: Optional[int], + body: Optional[Dict] = None, + auth: HTTPAuthorizationCredentials = Security(security), + ): + if body is None: + body = {} + auth_context = self.auth_wrapper(auth) + repository = self.piece_repository_repository.find_by_id(id=piece_repository_id) + if not repository: + raise HTTPException(status_code=ResourceNotFoundError().status_code, detail=ResourceNotFoundError().message) + workspace_associative_data = self.workspace_repository.find_by_id_and_user_id(id=repository.workspace_id, user_id=auth_context.user_id) + + if not workspace_associative_data: + raise HTTPException(status_code=ResourceNotFoundError().status_code, detail=ResourceNotFoundError().message) + + if workspace_associative_data and not workspace_associative_data.permission: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + if workspace_associative_data and workspace_associative_data.status != UserWorkspaceStatus.accepted.value: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + if workspace_associative_data.permission not in self.permission_level: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + + if not body or not getattr(body, "workspace_id", None): + return auth_context + + if body.workspace_id != repository.workspace_id: + raise HTTPException(status_code=ForbiddenError().status_code, detail=ForbiddenError().message) + return auth_context \ No newline at end of file diff --git a/rest/routers/piece_repository_router.py b/rest/routers/piece_repository_router.py index 056ded72..35ba295e 100644 --- a/rest/routers/piece_repository_router.py +++ b/rest/routers/piece_repository_router.py @@ -1,11 +1,9 @@ from fastapi import APIRouter, HTTPException, status, Depends, Response -from services.auth_service import AuthService from services.piece_repository_service import PieceRepositoryService from schemas.context.auth_context import AuthorizationContextData from schemas.requests.piece_repository import CreateRepositoryRequest, PatchRepositoryRequest, ListRepositoryFilters from schemas.responses.piece_repository import ( CreateRepositoryReponse, - PatchRepositoryResponse, GetRepositoryReleasesResponse, GetRepositoryReleaseDataResponse, GetWorkspaceRepositoriesResponse, @@ -15,12 +13,18 @@ from schemas.exceptions.base import BaseException, ConflictException, ForbiddenException, ResourceNotFoundException, UnauthorizedException from schemas.errors.base import ConflictError, ForbiddenError, ResourceNotFoundError, SomethingWrongError, UnauthorizedError from typing import List, Optional +from auth.permission_authorizer import Authorizer +from database.models.enums import Permission + router = APIRouter(prefix="/pieces-repositories") -auth_service = AuthService() + piece_repository_service = PieceRepositoryService() +admin_authorizer = Authorizer(permission_level=Permission.admin.value) +read_authorizer = Authorizer(permission_level=Permission.read.value) + @router.post( path="", @@ -36,7 +40,7 @@ ) def create_piece_repository( body: CreateRepositoryRequest, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_owner_access_authorizer_body) + auth_context: AuthorizationContextData = Depends(admin_authorizer.authorize_with_body) ) -> CreateRepositoryReponse: """ Create piece repository for workspace. @@ -67,7 +71,7 @@ def get_piece_repository_releases( source: RepositorySource, path: str, workspace_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> List[GetRepositoryReleasesResponse]: """Get piece repository releases""" try: @@ -97,7 +101,7 @@ def get_piece_repository_release_data( source: RepositorySource, path: str, workspace_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> GetRepositoryReleaseDataResponse: """Get piece repository release data""" try: @@ -120,7 +124,7 @@ def get_piece_repository_release_data( status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': SomethingWrongError}, status.HTTP_403_FORBIDDEN: {'model': ForbiddenError}, }, - dependencies=[Depends(auth_service.workspace_access_authorizer)] + dependencies=[Depends(read_authorizer.authorize)] ) def get_pieces_repositories( workspace_id: int, @@ -188,7 +192,7 @@ def get_pieces_repositories_worker( ) def delete_repository( piece_repository_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.piece_repository_workspace_owner_access_authorizer) + auth_context: AuthorizationContextData = Depends(admin_authorizer.authorize_piece_repository) ): try: response = piece_repository_service.delete_repository( @@ -209,10 +213,9 @@ def delete_repository( }, ) -@auth_service.authorize_repository_workspace_access def get_piece_repository( piece_repository_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize_piece_repository) ) -> GetRepositoryResponse: """Get piece repository info by id""" try: diff --git a/rest/routers/workspace_router.py b/rest/routers/workspace_router.py index 8ab900c0..73ccc746 100644 --- a/rest/routers/workspace_router.py +++ b/rest/routers/workspace_router.py @@ -13,15 +13,14 @@ from schemas.errors.base import ConflictError, ForbiddenError, SomethingWrongError, ResourceNotFoundError, UnauthorizedError from database.models.enums import UserWorkspaceStatus from typing import List -from auth.workspace_authorizer import WorkspaceAuthorizer -from auth.base_authorizer import BaseAuthorizer +from auth.permission_authorizer import Authorizer + router = APIRouter(prefix="/workspaces") -base_authorizer = BaseAuthorizer() -workspace_owner_authorizer = WorkspaceAuthorizer(permission_level='owner') -workspace_admin_authorizer = WorkspaceAuthorizer(permission_level='admin') -workspace_read_authorizer = WorkspaceAuthorizer(permission_level='read') +owner_authorizer = Authorizer(permission_level='owner') +admin_authorizer = Authorizer(permission_level='admin') +read_authorizer = Authorizer(permission_level='read') workspace_service = WorkspaceService() @@ -39,7 +38,7 @@ ) def create_workspace( body: CreateWorkspaceRequest, - auth_context: AuthorizationContextData = Depends(base_authorizer.auth_wrapper) + auth_context: AuthorizationContextData = Depends(read_authorizer.auth_wrapper) ) -> CreateWorkspaceResponse: """Create workspace""" try: @@ -64,7 +63,7 @@ def create_workspace( def list_user_workspaces( page: int = 0, page_size: int = 10, - auth_context: AuthorizationContextData = Depends(base_authorizer.auth_wrapper) + auth_context: AuthorizationContextData = Depends(read_authorizer.auth_wrapper) ) -> List[ListUserWorkspacesResponse]: """List user workspaces summary""" try: @@ -87,7 +86,7 @@ def list_user_workspaces( status.HTTP_404_NOT_FOUND: {'model': ResourceNotFoundError} }, ) -def get_workspace(workspace_id: int, auth_context: AuthorizationContextData = Depends(workspace_read_authorizer.authorize)) -> GetWorkspaceResponse: +def get_workspace(workspace_id: int, auth_context: AuthorizationContextData = Depends(read_authorizer.authorize)) -> GetWorkspaceResponse: """Get specific workspace data. Includes users, workflows and repositories""" try: response = workspace_service.get_workspace_data(workspace_id=workspace_id, auth_context=auth_context) @@ -110,7 +109,7 @@ def get_workspace(workspace_id: int, auth_context: AuthorizationContextData = De def add_user_to_workspace( workspace_id: int, body: AssignWorkspaceRequest, - auth_context: AuthorizationContextData = Depends(workspace_admin_authorizer.authorize) + auth_context: AuthorizationContextData = Depends(admin_authorizer.authorize) ): """Assign workspace to user with permission""" try: @@ -135,7 +134,7 @@ def add_user_to_workspace( ) def accept_workspace_invite( workspace_id: int, - auth_context: AuthorizationContextData = Depends(base_authorizer.auth_wrapper) + auth_context: AuthorizationContextData = Depends(read_authorizer.auth_wrapper) ) -> GetWorkspaceResponse: """ Accept workspace invite. @@ -164,7 +163,7 @@ def accept_workspace_invite( ) def reject_workspace_invite( workspace_id: int, - auth_context: AuthorizationContextData = Depends(base_authorizer.auth_wrapper) + auth_context: AuthorizationContextData = Depends(read_authorizer.auth_wrapper) ) -> GetWorkspaceResponse: """ Reject workspace invite. @@ -192,7 +191,7 @@ def reject_workspace_invite( status.HTTP_403_FORBIDDEN: {'model': ForbiddenError}, status.HTTP_409_CONFLICT: {'model': ConflictError} }, - dependencies=[Depends(workspace_owner_authorizer.authorize)] + dependencies=[Depends(owner_authorizer.authorize)] ) async def delete_workspace( workspace_id: int, @@ -219,7 +218,7 @@ async def delete_workspace( def patch_workspace( workspace_id: int, body: PatchWorkspaceRequest, - auth_context: AuthorizationContextData = Depends(workspace_owner_authorizer.authorize) + auth_context: AuthorizationContextData = Depends(owner_authorizer.authorize) ): try: response = workspace_service.patch_workspace( @@ -245,7 +244,7 @@ def patch_workspace( async def remove_user_from_workspace( workspace_id: int, user_id: int, - auth_context: AuthorizationContextData = Depends(workspace_admin_authorizer.authorize) + auth_context: AuthorizationContextData = Depends(admin_authorizer.authorize) ): try: await workspace_service.remove_user_from_workspace( @@ -270,7 +269,7 @@ def list_workspace_users( workspace_id: int, page: int = 0, page_size: int = 10, - auth_context: AuthorizationContextData = Depends(workspace_read_authorizer.authorize) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> ListWorkspaceUsersResponse: try: return workspace_service.list_workspace_users( From ad57e71d6990a9909551b23103e3d002ca4ee68b Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 16:13:38 -0300 Subject: [PATCH 08/11] update piece router authorizer --- rest/routers/piece_router.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/rest/routers/piece_router.py b/rest/routers/piece_router.py index 9e45e1df..05b9a173 100644 --- a/rest/routers/piece_router.py +++ b/rest/routers/piece_router.py @@ -1,18 +1,18 @@ from fastapi import APIRouter, HTTPException, status, Depends -from services.auth_service import AuthService from services.piece_service import PieceService from schemas.context.auth_context import AuthorizationContextData from schemas.requests.piece import ListPiecesFilters from schemas.responses.piece import GetPiecesResponse -from schemas.exceptions.base import BaseException, ForbiddenException, ResourceNotFoundException +from schemas.exceptions.base import BaseException, ForbiddenException from schemas.errors.base import SomethingWrongError, ForbiddenError from typing import List - +from auth.permission_authorizer import Authorizer +from database.models.enums import Permission router = APIRouter(prefix="/pieces-repositories/{piece_repository_id}/pieces") piece_service = PieceService() -auth_service = AuthService() +read_authorizer = Authorizer(permission_level=Permission.read.value) @router.get( @@ -24,13 +24,12 @@ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': SomethingWrongError} } ) -@auth_service.authorize_repository_workspace_access def get_pieces( piece_repository_id: int, page: int = 0, page_size: int = 100, filters: ListPiecesFilters = Depends(), - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize_piece_repository) ): """List pieces from a piece repository""" try: From 3c27db7405884da88a009c0dbdb1141bf1d93096 Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 16:33:08 -0300 Subject: [PATCH 09/11] secret router authorizer --- rest/routers/secret_router.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/rest/routers/secret_router.py b/rest/routers/secret_router.py index f0a4c535..34d33720 100644 --- a/rest/routers/secret_router.py +++ b/rest/routers/secret_router.py @@ -1,5 +1,4 @@ from fastapi import APIRouter, HTTPException, Depends, status -from services.auth_service import AuthService from services.secret_service import SecretService from schemas.context.auth_context import AuthorizationContextData from schemas.requests.secret import PatchSecretValueRequest @@ -7,13 +6,19 @@ from schemas.exceptions.base import BaseException, ForbiddenException, ResourceNotFoundException from schemas.errors.base import ResourceNotFoundError, SomethingWrongError, ForbiddenError from typing import List +from auth.permission_authorizer import Authorizer +from database.models.enums import Permission router = APIRouter(prefix="/pieces-repositories/{piece_repository_id}/secrets") -auth_service = AuthService() secret_service = SecretService() +read_authorizer = Authorizer(permission_level=Permission.read.value) +admin_authorizer = Authorizer(permission_level=Permission.admin.value) + + + @router.get( '', status_code=200, @@ -23,10 +28,9 @@ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': SomethingWrongError} } ) -@auth_service.authorize_repository_workspace_access def get_repository_secrets( piece_repository_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize_piece_repository) ) -> List[ListRepositorySecretsResponse]: """ Get the list of piece repository secrets. @@ -49,12 +53,11 @@ def get_repository_secrets( status.HTTP_404_NOT_FOUND: {'model': ResourceNotFoundError} } ) -@auth_service.authorize_repository_workspace_owner_access # To update a secret user must have owner access to workspace def update_repository_secret( piece_repository_id: int, secret_id: int, body: PatchSecretValueRequest, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(admin_authorizer.authorize_piece_repository) ): """ Update an piece repository secret value. From 819a6f0d29006fd3103cf9c362b127c4c98b2cec Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 16:34:02 -0300 Subject: [PATCH 10/11] user router authorizer --- rest/routers/user_router.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rest/routers/user_router.py b/rest/routers/user_router.py index 5f808d08..a94c80a9 100644 --- a/rest/routers/user_router.py +++ b/rest/routers/user_router.py @@ -4,12 +4,12 @@ from schemas.exceptions.base import BaseException, ForbiddenException, UnauthorizedException from schemas.errors.base import SomethingWrongError, UnauthorizedError, ForbiddenError from schemas.context.auth_context import AuthorizationContextData -from services.auth_service import AuthService +from auth.permission_authorizer import Authorizer router = APIRouter(prefix="/users") user_service = UserService() -auth_service = AuthService() +authorizer = Authorizer() @router.delete( "/{user_id}", @@ -21,8 +21,8 @@ } ) async def delete_user( - user_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + user_id: int, + auth_context: AuthorizationContextData = Depends(authorizer.auth_wrapper) ): """ Delete user by id. From aeb2b8dddef688f5b478ead91a812d15b76d0f80 Mon Sep 17 00:00:00 2001 From: vinicvaz Date: Fri, 22 Mar 2024 16:47:04 -0300 Subject: [PATCH 11/11] workflow router authorizer --- .../workspaceSettings/UsersCard.tsx | 2 +- rest/routers/workflow_router.py | 29 ++++++++++--------- rest/services/workflow_service.py | 7 ++++- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/frontend/src/features/workspaces/components/workspaceSettings/UsersCard.tsx b/frontend/src/features/workspaces/components/workspaceSettings/UsersCard.tsx index cba41598..f67b7433 100644 --- a/frontend/src/features/workspaces/components/workspaceSettings/UsersCard.tsx +++ b/frontend/src/features/workspaces/components/workspaceSettings/UsersCard.tsx @@ -89,8 +89,8 @@ export const UsersCard: FC = () => { }} > Admin - Read Write + Read diff --git a/rest/routers/workflow_router.py b/rest/routers/workflow_router.py index e8625987..6fa9b443 100644 --- a/rest/routers/workflow_router.py +++ b/rest/routers/workflow_router.py @@ -2,7 +2,6 @@ from schemas.context.auth_context import AuthorizationContextData from typing import List from services.workflow_service import WorkflowService -from services.auth_service import AuthService from schemas.requests.workflow import CreateWorkflowRequest, ListWorkflowsFilters from schemas.responses.workflow import ( GetWorkflowsResponse, @@ -28,11 +27,16 @@ ResourceNotFoundError, SomethingWrongError, ) +from auth.permission_authorizer import Authorizer +from database.models.enums import Permission router = APIRouter(prefix="/workspaces/{workspace_id}/workflows") -auth_service = AuthService() + workflow_service = WorkflowService() +read_authorizer = Authorizer(permission_level=Permission.read.value) +write_authorizer = Authorizer(permission_level=Permission.write.value) + @router.post( @@ -48,7 +52,7 @@ def create_workflow( workspace_id: int, body: CreateWorkflowRequest, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(write_authorizer.authorize) ) -> CreateWorkflowResponse: """Create a new workflow""" try: @@ -69,7 +73,7 @@ def create_workflow( status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": SomethingWrongError}, status.HTTP_403_FORBIDDEN: {"model": ForbiddenError}, }, - dependencies=[Depends(auth_service.workspace_access_authorizer)] + dependencies=[Depends(read_authorizer.authorize)] ) async def list_workflows( workspace_id: int, @@ -99,11 +103,10 @@ async def list_workflows( }, status_code=200, ) -@auth_service.authorize_workspace_access def get_workflow( workspace_id: int, workflow_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.auth_wrapper) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> GetWorkflowResponse: """Get a workflow information""" try: @@ -125,7 +128,7 @@ def get_workflow( status.HTTP_403_FORBIDDEN: {"model": ForbiddenError}, status.HTTP_404_NOT_FOUND: {"model": ResourceNotFoundError} }, - dependencies=[Depends(auth_service.workspace_owner_access_authorizer)] + dependencies=[Depends(write_authorizer.authorize)] ) async def delete_workflow( workspace_id: int, @@ -154,7 +157,7 @@ async def delete_workflow( def run_workflow( workspace_id: int, workflow_id: int, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(write_authorizer.authorize) ): try: return workflow_service.run_workflow( @@ -178,7 +181,7 @@ def list_workflow_runs( workflow_id: int, page: int = 0, page_size: int = 5, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> GetWorkflowRunsResponse: try: return workflow_service.list_workflow_runs( @@ -205,7 +208,7 @@ def list_run_tasks( workflow_run_id: str, page: int = 0, page_size: int = 5, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> GetWorkflowRunTasksResponse: try: return workflow_service.list_run_tasks( @@ -231,7 +234,7 @@ def generate_report( workspace_id: int, workflow_id: int, workflow_run_id: str, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> GetWorkflowResultReportResponse: try: return workflow_service.generate_report( @@ -257,7 +260,7 @@ def get_task_logs( workflow_run_id: str, task_id: str, task_try_number: int, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> GetWorkflowRunTaskLogsResponse: """ @@ -290,7 +293,7 @@ def get_task_result( workflow_run_id: str, task_id: str, task_try_number: int, - auth_context: AuthorizationContextData = Depends(auth_service.workspace_access_authorizer) + auth_context: AuthorizationContextData = Depends(read_authorizer.authorize) ) -> GetWorkflowRunTaskResultResponse: """ diff --git a/rest/services/workflow_service.py b/rest/services/workflow_service.py index 6715e660..7a32010d 100644 --- a/rest/services/workflow_service.py +++ b/rest/services/workflow_service.py @@ -647,7 +647,12 @@ def list_workflow_runs(self, workflow_id: int, page: int, page_size: int): data = [] for run in dag_runs: - #duration = run.get('end_date') - run.get('start_date') + if run.get('end_date') is None or run.get('start_date') is None: + run['duration_in_seconds'] = None + data.append( + GetWorkflowRunsResponseData(**run) + ) + continue end_date_dt = datetime.fromisoformat(run.get('end_date')) start_date_dt = datetime.fromisoformat(run.get('start_date')) duration = end_date_dt - start_date_dt