Update: Sheet 4 access control

oh_security
TAMDeveloper13 3 months ago
parent 2615ff9a6e
commit d4175f75d0

@ -87,6 +87,42 @@ class AccessControl:
raise self.permission_exception raise self.permission_exception
# Roles allowed to access the backend (whitelist).
# Management is intentionally excluded.
ALLOWED_ROLES = {"admin", "super admin", "engineer"}
def require_any_role(*allowed_roles: str):
"""
FastAPI dependency whitelist-based role check.
Raises 403 if the authenticated user's role is not in the allowed set.
Comparison is case-insensitive.
Usage (router-level):
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
"""
normalized = {r.lower() for r in allowed_roles}
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 = user.get("role") if isinstance(user, dict) else getattr(user, "role", None)
if not user_role or user_role.lower() not in normalized:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your role does not have permission to access this resource.",
)
return True
return dependency
def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any): def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any):
""" """
FastAPI dependency for role-based access control. FastAPI dependency for role-based access control.

@ -11,11 +11,11 @@ from src.database.core import CollectorDbSession, DbSession
from src.models import StandardResponse from src.models import StandardResponse
from .service import get_all_budget_constrains from .service import get_all_budget_constrains
from src.auth.access_control import required_permission, OHPermission from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends from fastapi import Depends
router = APIRouter() router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("/{session_id}", response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) @router.get("/{session_id}", response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])

@ -25,6 +25,7 @@ from src.database.core import CollectorDbSession
from src.auth.access_control import required_permission, OHPermission from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends from fastapi import Depends
from src.csrf_protect import csrf_protect
router = APIRouter() router = APIRouter()
get_calculation = APIRouter() get_calculation = APIRouter()
@ -176,7 +177,7 @@ async def get_simulation_result(
) )
@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope))]) @router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope)), Depends(csrf_protect)])
async def update_selected_equipment( async def update_selected_equipment(
db_session: DbSession, db_session: DbSession,
calculation_id, calculation_id,
@ -194,4 +195,4 @@ async def update_selected_equipment(
return StandardResponse( return StandardResponse(
data=results, data=results,
message="Data retrieved successfully", message="Data retrieved successfully",
) )

@ -93,4 +93,7 @@ API_KEY = config("API_KEY")
TR_RBD_ID = config("TR_RBD_ID") TR_RBD_ID = config("TR_RBD_ID")
TC_RBD_ID = config("TC_RBD_ID") TC_RBD_ID = config("TC_RBD_ID")
DEFAULT_TC_ID = config("DEFAULT_TC_ID") DEFAULT_TC_ID = config("DEFAULT_TC_ID")
RATELIMIT_STORAGE_URI = f"redis://{config('REDIS_HOST')}:{config('REDIS_PORT')}" REDIS_HOST = config("REDIS_HOST")
REDIS_PORT = config("REDIS_PORT", cast=int)
RATELIMIT_STORAGE_URI = f"redis://{REDIS_HOST}:{REDIS_PORT}"
REDIS_AUTH_DB = config("REDIS_AUTH_DB", cast=int)

@ -1,83 +1,125 @@
"""
CSRF Token Verification Dependency for be-optimumoh (FastAPI).
import secrets Menggantikan CSRFProtectMiddleware (Double Submit Cookie) dengan verifikasi
berbasis Redis. Token CSRF di-generate oleh be-auth (CSRFTokenResource) dan
disimpan di Redis dalam bentuk SHA256 hash.
Token Flow (generation di be-auth, verification di sini):
GENERATION (be-auth / CSRFTokenResource):
1. token = secrets.token_urlsafe(32)
2. token_hash = SHA256(token)
3. redis[csrf:{user_id}:{path_hash}] = token_hash (dengan TTL)
4. return plain token ke client
VERIFICATION (dependency ini):
1. Baca X-CSRF-Token header dari request
2. Ambil user_id dari request.state.user (di-set oleh JWTBearer)
3. incoming_hash = SHA256(nilai X-CSRF-Token)
4. stored_hash = redis_auth.get("csrf:{user_id}:{path_hash}")
5. secrets.compare_digest(incoming_hash, stored_hash)
6. redis_auth.delete(key) one-time use
NOTE: Dependency ini harus selalu dikombinasikan dengan JWTBearer() agar
request.state.user sudah tersedia saat dijalankan.
Usage:
from fastapi import Depends
from src.csrf_protect import csrf_protect
@router.post("/some-endpoint", dependencies=[Depends(csrf_protect)])
async def some_endpoint(...):
...
"""
import hashlib
import logging import logging
from typing import Optional, List import secrets
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware import redis
from starlette.responses import Response from fastapi import HTTPException, Request
import src.config as config
from src.context import get_user_id
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class CSRFProtectMiddleware(BaseHTTPMiddleware): # Koneksi Redis untuk CSRF — Redis DB 0 tempat be-auth menyimpan CSRF token
_redis_auth = redis.Redis(
host=config.REDIS_HOST,
port=int(config.REDIS_PORT),
db=config.REDIS_AUTH_DB,
)
async def csrf_protect(request: Request) -> None:
""" """
CSRF Protection Middleware using Double Submit Cookie pattern. FastAPI dependency untuk verifikasi CSRF token berbasis Redis.
Harus dikombinasikan dengan JWTBearer() dependency.
This middleware:
1. Ensures a 'csrf_token' cookie exists for every request.
2. For mutating requests (POST, PUT, PATCH, DELETE):
- Requires an 'X-CSRF-Token' header.
- Validates that the header value matches the 'csrf_token' cookie.
""" """
# ── Step 1: ambil user_id dari request.state (di-set oleh JWTBearer) ────
def __init__( user = getattr(request.state, "user", None)
self, user_id = str(user.user_id) if user else get_user_id()
app,
cookie_name: str = "csrf_token", if not user_id:
header_name: str = "X-CSRF-Token", raise HTTPException(status_code=401, detail="Authentication required")
excluded_paths: Optional[List[str]] = None,
safe_methods: List[str] = ("GET", "HEAD", "OPTIONS", "TRACE") # ── Step 2: baca CSRF token dari request header ───────────────────────────
): # Starlette lowercases all header names
super().__init__(app) csrf_token_header = (
self.cookie_name = cookie_name request.headers.get("x-csrf-token")
self.header_name = header_name or request.headers.get("x-xsrf-token")
self.excluded_paths = excluded_paths or [] or request.headers.get("x-csrf-token")
self.safe_methods = safe_methods )
async def dispatch(self, request: Request, call_next): if not csrf_token_header:
# 1. Skip validation for "safe" methods (GET, HEAD, etc.) or excluded paths log.warning(
is_safe_method = request.method in self.safe_methods "[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s",
is_excluded = any(request.url.path.startswith(path) for path in self.excluded_paths) user_id,
request.client.host if request.client else "unknown",
if not is_safe_method and not is_excluded: )
# 2. Extract token from cookie and header raise HTTPException(status_code=403, detail="CSRF token missing in header")
csrf_cookie = request.cookies.get(self.cookie_name)
csrf_header = request.headers.get(self.header_name.lower()) # Starlette lowercase headers csrf_token_header = csrf_token_header.strip()
# 3. Validate # ── Step 3: bangun Redis key (harus sama dengan format di be-auth) ─────────
if not csrf_cookie: current_path = request.url.path
log.warning(f"CSRF validation failed: Missing cookie '{self.cookie_name}'") path_hash = hashlib.sha256(current_path.encode()).hexdigest()[:16]
raise HTTPException( redis_key = f"csrf:{user_id}:{path_hash}"
status_code=403,
detail="CSRF validation failed: Missing session cookie." # ── Step 4: ambil stored token hash dari Redis (DB 0) ────────────────────
) stored_token_hash = _redis_auth.get(redis_key)
if not stored_token_hash:
if not csrf_header: log.warning(
log.warning(f"CSRF validation failed: Missing header '{self.header_name}'") "[CSRF] Token not found in Redis | user_id=%s | path=%s | IP: %s",
raise HTTPException( user_id,
status_code=403, current_path,
detail=f"CSRF validation failed: Missing header '{self.header_name}'." request.client.host if request.client else "unknown",
) )
raise HTTPException(status_code=403, detail="CSRF token expired or invalid")
if not secrets.compare_digest(csrf_cookie, csrf_header):
log.warning(f"CSRF validation failed: Token mismatch for path {request.url.path}") if isinstance(stored_token_hash, bytes):
raise HTTPException( stored_token_hash = stored_token_hash.decode("utf-8")
status_code=403,
detail="CSRF validation failed: Token mismatch." # ── Step 5: bandingkan hash dengan constant-time digest ───────────────────
) incoming_hash = hashlib.sha256(csrf_token_header.encode()).hexdigest()
if not secrets.compare_digest(incoming_hash, stored_token_hash):
# 4. Proceed with the request log.warning(
response: Response = await call_next(request) "[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s",
user_id,
# 5. Ensure the cookie is set if missing (e.g., on first GET request) current_path,
if not request.cookies.get(self.cookie_name): request.client.host if request.client else "unknown",
new_token = secrets.token_urlsafe(32) )
response.set_cookie( _redis_auth.delete(redis_key)
key=self.cookie_name, raise HTTPException(status_code=403, detail="CSRF token verification failed")
value=new_token,
httponly=False, # Set to False so frontend JS can read it for Double Submit # ── Step 6: hapus token (one-time use) dan lanjutkan ─────────────────────
samesite="lax", _redis_auth.delete(redis_key)
secure=True, # Recommended for production with HTTPS log.info(
path="/" "[CSRF] Verified & deleted | user_id=%s | path=%s",
) user_id,
current_path,
return response )

@ -1,18 +1,19 @@
from typing import Dict, List from typing import Dict, List
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession, from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate) search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (ScopeEquipmentActivityCreate, from .schema import (ScopeEquipmentActivityCreate,
ScopeEquipmentActivityPagination, ScopeEquipmentActivityPagination,
ScopeEquipmentActivityRead, ScopeEquipmentActivityUpdate) ScopeEquipmentActivityRead, ScopeEquipmentActivityUpdate)
from .service import get_all from .service import get_all
router = APIRouter() router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("/{location_tag}", response_model=StandardResponse[List[Dict]]) @router.get("/{location_tag}", response_model=StandardResponse[List[Dict]])

@ -31,7 +31,6 @@ from src.enums import ResponseStatus
from src.exceptions import handle_exception from src.exceptions import handle_exception
from src.logging import configure_logging from src.logging import configure_logging
from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware
from src.csrf_protect import CSRFProtectMiddleware
from src.rate_limiter import limiter from src.rate_limiter import limiter
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
@ -137,10 +136,10 @@ security_headers_middleware(app)
app.add_middleware(RequestValidationMiddleware) app.add_middleware(RequestValidationMiddleware)
app.add_middleware( # app.add_middleware(
CSRFProtectMiddleware, # CSRFProtectMiddleware,
excluded_paths=["/healthcheck"] # excluded_paths=["/healthcheck"]
) # )

@ -1,18 +1,20 @@
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.csrf_protect import csrf_protect
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession, from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate) search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (OverhaulActivityCreate, OverhaulActivityPagination, from .schema import (OverhaulActivityCreate, OverhaulActivityPagination,
OverhaulActivityRead, OverhaulActivityUpdate) OverhaulActivityRead, OverhaulActivityUpdate)
from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session, update from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session, update
router = APIRouter() router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get( @router.get(
@ -109,6 +111,7 @@ async def get_overhaul_equipment(
@router.post( @router.post(
"/delete/{overhaul_session}/{location_tag}", "/delete/{overhaul_session}/{location_tag}",
response_model=StandardResponse[None], response_model=StandardResponse[None],
dependencies=[Depends(csrf_protect)],
) )
async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session:UUID): async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session:UUID):
await remove_equipment_from_session(db_session=db_session, overhaul_session_id=overhaul_session, location_tag=location_tag) await remove_equipment_from_session(db_session=db_session, overhaul_session_id=overhaul_session, location_tag=location_tag)

@ -1,10 +1,11 @@
import re import re
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.csrf_protect import csrf_protect
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters from src.database.service import CommonParameters
from src.models import StandardResponse from src.models import StandardResponse
@ -64,7 +65,7 @@ async def get_gantt_spreadsheet(db_session: DbSession):
) )
@router.post( @router.post(
"/spreadsheet", response_model=StandardResponse[dict] "/spreadsheet", response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)]
) )
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn): async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn):
"""Get all scope pagination.""" """Get all scope pagination."""

@ -11,10 +11,11 @@ from src.models import StandardResponse
from .model import OverhaulScope from .model import OverhaulScope
from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate
from .service import create, delete, get, get_all, update,get_all_oh_with_history_service 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 src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.csrf_protect import csrf_protect
from fastapi import Depends from fastapi import Depends
router = APIRouter() router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("", response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) @router.get("", response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@ -64,7 +65,7 @@ async def update_scope(
) )
@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope))]) @router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope)), Depends(csrf_protect)])
async def delete_scope(db_session: DbSession, scope_id: str): async def delete_scope(db_session: DbSession, scope_id: str):
scope = await get(db_session=db_session, overhaul_session_id=scope_id) scope = await get(db_session=db_session, overhaul_session_id=scope_id)

@ -1,14 +1,13 @@
from fastapi import APIRouter, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.csrf_protect import csrf_protect
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession, from src.database.service import (CommonParameters, DbSession, search_filter_sort_paginate)
search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.sparepart.schema import SparepartRemark from src.sparepart.schema import SparepartRemark
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .service import create_remark, get_spareparts_paginated from .service import create_remark, get_spareparts_paginated
router = APIRouter() router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("", response_model=StandardResponse[list]) @router.get("", response_model=StandardResponse[list])
@ -17,15 +16,13 @@ async def get_sparepart(collector_db_session:CollectorDbSession, db_session: DbS
# return # return
data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session) data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session)
return StandardResponse( return StandardResponse(
data=data, data=data,
message="Data retrieved successfully", message="Data retrieved successfully",
) )
@router.post("", response_model=StandardResponse[SparepartRemark]) @router.post("", response_model=StandardResponse[SparepartRemark], dependencies=[Depends(csrf_protect)])
async def create_remark_route(collector_db_session:CollectorDbSession, db_session: DbSession, remark_in:SparepartRemark): async def create_remark_route(collector_db_session:CollectorDbSession, db_session: DbSession, remark_in:SparepartRemark):
sparepart_remark = await create_remark(db_session=db_session, collector_db_session=collector_db_session, remark_in=remark_in) sparepart_remark = await create_remark(db_session=db_session, collector_db_session=collector_db_session, remark_in=remark_in)

@ -1,19 +1,20 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.params import Query from fastapi.params import Query
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import DbSession, CollectorDbSession from src.database.core import DbSession, CollectorDbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.models import StandardResponse from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (MasterEquipmentPagination, ScopeEquipmentCreate, from .schema import (MasterEquipmentPagination, ScopeEquipmentCreate,
ScopeEquipmentPagination, ScopeEquipmentRead, ScopeEquipmentPagination, ScopeEquipmentRead,
ScopeEquipmentUpdate) ScopeEquipmentUpdate)
from .service import (create, delete, get_all, get_all_master_equipment, update, get_history_standard_scope_wo_service) from .service import (create, delete, get_all, get_all_master_equipment, update, get_history_standard_scope_wo_service)
from uuid import UUID from uuid import UUID
router = APIRouter() router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("", response_model=StandardResponse[ScopeEquipmentPagination]) @router.get("", response_model=StandardResponse[ScopeEquipmentPagination])

Loading…
Cancel
Save