diff --git a/src/auth/access_control.py b/src/auth/access_control.py new file mode 100644 index 0000000..c907dbc --- /dev/null +++ b/src/auth/access_control.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Any, List, Union +from fastapi import Request, HTTPException, status + +Allow: str = "allow" +Deny: str = "deny" + +@dataclass(frozen=True) +class Principal: + key: str + value: str + + def __repr__(self) -> str: + return f"{self.key}:{self.value}" + + def __str__(self) -> str: + return self.__repr__() + +@dataclass(frozen=True) +class SystemPrincipal(Principal): + def __init__(self, value: str): + super().__init__(key="system", value=value) + +@dataclass(frozen=True) +class RolePrincipal(Principal): + def __init__(self, value: str): + super().__init__(key="role", value=value) + +Everyone = SystemPrincipal(value="everyone") +Authenticated = SystemPrincipal(value="authenticated") + + +class OHPermission(Enum): + CREATE = "create" + READ = "read" + EDIT = "edit" + DELETE = "delete" + + +class AccessControl: + def __init__(self, permission_exception: Any = None): + self.permission_exception = permission_exception or HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied" + ) + + def _acl(self, resource): + acl = getattr(resource, "__acl__", []) + if callable(acl): + return acl() + return acl + + def has_permission( + self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any + ) -> bool: + if not isinstance(resource, list): + resource = [resource] + + permits = [] + for res in resource: + granted = False + acl = self._acl(res) + + for action, principal, permissions in acl: + # Check if any of the required permissions are in the allowed permissions for this principal + is_permitted = any(p in permissions for p in required_permissions) + + if (action == Allow and is_permitted) and ( + principal in principals or principal == Everyone + ): + granted = True + break + elif (action == Deny and is_permitted) and ( + principal in principals or principal == Everyone + ): + granted = False + break + + permits.append(granted) + + return all(permits) + + def assert_access( + self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any + ): + if not self.has_permission(principals, required_permissions, resource): + raise self.permission_exception + + +def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any): + """ + FastAPI dependency for role-based access control. + """ + if isinstance(permissions, OHPermission): + permissions = [permissions] + + async def dependency(request: Request): + user = getattr(request.state, "user", None) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated" + ) + + user_role = None + if isinstance(user, dict): + user_role = user.get("role") + else: + user_role = getattr(user, "role", None) + + if not user_role: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="No user role found" + ) + + user_principals = [Authenticated, RolePrincipal(user_role)] + + ac = AccessControl() + ac.assert_access(user_principals, permissions, resources) + + return True + + return dependency diff --git a/src/calculation_budget_constrains/router.py b/src/calculation_budget_constrains/router.py index 4ea030e..542a987 100644 --- a/src/calculation_budget_constrains/router.py +++ b/src/calculation_budget_constrains/router.py @@ -11,11 +11,14 @@ from src.database.core import CollectorDbSession, DbSession from src.models import StandardResponse from .service import get_all_budget_constrains +from src.auth.access_control import required_permission, OHPermission +from src.overhaul_scope.model import OverhaulScope +from fastapi import Depends router = APIRouter() -@router.get("/{session_id}", response_model=StandardResponse[Dict]) +@router.get("/{session_id}", response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_target_reliability( db_session: DbSession, token: Token, diff --git a/src/calculation_target_reliability/router.py b/src/calculation_target_reliability/router.py index 813a30b..f72cf5d 100644 --- a/src/calculation_target_reliability/router.py +++ b/src/calculation_target_reliability/router.py @@ -13,6 +13,9 @@ from src.models import StandardResponse from .service import run_rbd_simulation, get_simulation_results, identify_worst_eaf_contributors from .schema import OptimizationResult, TargetReliabiltiyQuery +from src.auth.access_control import required_permission, OHPermission +from src.overhaul_scope.model import OverhaulScope +from fastapi import Depends router = APIRouter() @@ -33,7 +36,7 @@ router = APIRouter() # ) -@router.get("", response_model=StandardResponse[OptimizationResult]) +@router.get("", response_model=StandardResponse[OptimizationResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_target_reliability( db_session: DbSession, token: Token, diff --git a/src/calculation_time_constrains/router.py b/src/calculation_time_constrains/router.py index 0cbbac2..30abd14 100644 --- a/src/calculation_time_constrains/router.py +++ b/src/calculation_time_constrains/router.py @@ -22,13 +22,17 @@ from .schema import (CalculationResultsRead, from .service import (bulk_update_equipment, get_calculation_result, get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations) from src.database.core import CollectorDbSession +from src.auth.access_control import required_permission, OHPermission +from src.overhaul_scope.model import OverhaulScope +from fastapi import Depends router = APIRouter() get_calculation = APIRouter() @router.post( - "", response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]] + "", response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]], + dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))] ) async def create_calculation_time_constrains( token: Token, @@ -66,7 +70,8 @@ async def create_calculation_time_constrains( @router.get( - "", response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]] + "", response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]], + dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))] ) async def get_all_simulation_calculations( db_session: DbSession, @@ -95,6 +100,7 @@ async def get_all_simulation_calculations( CalculationTimeConstrainsParametersRead, ] ], + dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))] ) async def get_calculation_parameters( db_session: DbSession, calculation_id: Optional[str] = Query(default=None) @@ -112,7 +118,8 @@ async def get_calculation_parameters( @get_calculation.get( - "/{calculation_id}", response_model=StandardResponse[CalculationTimeConstrainsRead] + "/{calculation_id}", response_model=StandardResponse[CalculationTimeConstrainsRead], + dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))] ) async def get_calculation_results(db_session: DbSession, calculation_id, token:InternalKey, include_risk_cost:int = Query(1, alias="risk_cost")): if calculation_id == 'default': @@ -132,7 +139,8 @@ async def get_calculation_results(db_session: DbSession, calculation_id, token:I ) @router.get( - "/{calculation_id}/{assetnum}", response_model=StandardResponse[EquipmentResult] + "/{calculation_id}/{assetnum}", response_model=StandardResponse[EquipmentResult], + dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))] ) async def get_calculation_per_equipment(db_session: DbSession, calculation_id, assetnum): @@ -150,6 +158,7 @@ async def get_calculation_per_equipment(db_session: DbSession, calculation_id, a @router.post( "/{calculation_id}/simulation", response_model=StandardResponse[CalculationResultsRead], + dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))] ) async def get_simulation_result( db_session: DbSession, @@ -167,7 +176,7 @@ async def get_simulation_result( ) -@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]]) +@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope))]) async def update_selected_equipment( db_session: DbSession, calculation_id, diff --git a/src/overhaul/router.py b/src/overhaul/router.py index dbe0621..f2575d7 100644 --- a/src/overhaul/router.py +++ b/src/overhaul/router.py @@ -13,11 +13,14 @@ from src.overhaul_scope.schema import ScopeRead from .schema import (OverhaulCriticalParts, OverhaulRead, OverhaulSystemComponents) +from src.auth.access_control import required_permission, OHPermission +from src.overhaul_scope.model import OverhaulScope +from fastapi import Depends router = APIRouter() -@router.get("", response_model=StandardResponse[OverhaulRead]) +@router.get("", response_model=StandardResponse[OverhaulRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_overhaul(db_session: DbSession, token:Token, collector_db_session:CollectorDbSession): """Get all scope pagination.""" overview = await get_overhaul_overview(db_session=db_session) @@ -36,7 +39,7 @@ async def get_overhaul(db_session: DbSession, token:Token, collector_db_session: ) -@router.get("/schedules", response_model=StandardResponse[List[ScopeRead]]) +@router.get("/schedules", response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_schedules(): """Get all overhaul schedules.""" schedules = get_overhaul_schedules() @@ -46,7 +49,7 @@ async def get_schedules(): ) -@router.get("/critical-parts", response_model=StandardResponse[OverhaulCriticalParts]) +@router.get("/critical-parts", response_model=StandardResponse[OverhaulCriticalParts], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_critical_parts(): """Get all overhaul critical parts.""" criticalParts = get_overhaul_critical_parts() @@ -57,7 +60,8 @@ async def get_critical_parts(): @router.get( - "/system-components", response_model=StandardResponse[OverhaulSystemComponents] + "/system-components", response_model=StandardResponse[OverhaulSystemComponents], + dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))] ) async def get_system_components(): """Get all overhaul system components.""" diff --git a/src/overhaul_scope/model.py b/src/overhaul_scope/model.py index 1b228ba..9c29297 100644 --- a/src/overhaul_scope/model.py +++ b/src/overhaul_scope/model.py @@ -6,6 +6,8 @@ from src.database.core import Base from src.models import DefaultMixin, IdentityMixin, TimeStampMixin +from src.auth.access_control import Allow, RolePrincipal, OHPermission + class OverhaulScope(Base, DefaultMixin): __tablename__ = "oh_ms_overhaul" start_date = Column(DateTime(timezone=True), nullable=False) # Made non-nullable to match model @@ -17,7 +19,25 @@ class OverhaulScope(Base, DefaultMixin): UUID(as_uuid=True), ForeignKey("oh_ms_maintenance_type.id"), nullable=False) wo_parent = Column(JSON, nullable=True) + @classmethod + def __acl__(self): + basic_permissions = [OHPermission.READ] + engineer_permissions = [ + OHPermission.READ, + OHPermission.CREATE, + OHPermission.EDIT, + OHPermission.DELETE, + ] + all_permissions = list(OHPermission) + + return [ + (Allow, RolePrincipal("Management"), basic_permissions), + (Allow, RolePrincipal("Engineer"), engineer_permissions), + (Allow, RolePrincipal("Admin"), all_permissions), + ] + maintenance_type = relationship("MaintenanceType", lazy="selectin", backref="overhaul_scopes") + # activity_equipments = relationship("OverhaulActivity", lazy="selectin") diff --git a/src/overhaul_scope/router.py b/src/overhaul_scope/router.py index 9a069c6..2eb5c75 100644 --- a/src/overhaul_scope/router.py +++ b/src/overhaul_scope/router.py @@ -11,11 +11,13 @@ from src.models import StandardResponse from .model import OverhaulScope from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate from .service import create, delete, get, get_all, update,get_all_oh_with_history_service +from src.auth.access_control import required_permission, OHPermission +from fastapi import Depends router = APIRouter() -@router.get("", response_model=StandardResponse[ScopePagination]) +@router.get("", response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_scopes(common: CommonParameters, scope_name: Optional[str] = None): """Get all scope pagination.""" # return @@ -26,11 +28,11 @@ async def get_scopes(common: CommonParameters, scope_name: Optional[str] = None) message="Data retrieved successfully", ) -@router.get("/history", response_model=StandardResponse[List[ScopeRead]]) +@router.get("/history", response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_history(db_session: DbSession): return StandardResponse(data=await get_all_oh_with_history_service(db_session=db_session), message="Data retrieved successfully") -@router.get("/{overhaul_session_id}", response_model=StandardResponse[ScopeRead]) +@router.get("/{overhaul_session_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_scope(db_session: DbSession, overhaul_session_id: str): scope = await get(db_session=db_session, overhaul_session_id=overhaul_session_id) if not scope: @@ -42,14 +44,14 @@ async def get_scope(db_session: DbSession, overhaul_session_id: str): return StandardResponse(data=scope, message="Data retrieved successfully") -@router.post("", response_model=StandardResponse[ScopeRead]) +@router.post("", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))]) async def create_scope(db_session: DbSession, scope_in: ScopeCreate): scope = await create(db_session=db_session, scope_in=scope_in) return StandardResponse(data=scope, message="Data created successfully") -@router.post("/update/{scope_id}", response_model=StandardResponse[ScopeRead]) +@router.post("/update/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope))]) async def update_scope( db_session: DbSession, scope_id: str, @@ -70,7 +72,7 @@ async def update_scope( ) -@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead]) +@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope))]) async def delete_scope(db_session: DbSession, scope_id: str): scope = await get(db_session=db_session, overhaul_session_id=scope_id, for_update=True)