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
# 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):
"""
FastAPI dependency for role-based access control.

@ -11,11 +11,11 @@ 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.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.overhaul_scope.model import OverhaulScope
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))])

@ -25,6 +25,7 @@ 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
from src.csrf_protect import csrf_protect
router = 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(
db_session: DbSession,
calculation_id,
@ -194,4 +195,4 @@ async def update_selected_equipment(
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
)

@ -93,4 +93,7 @@ API_KEY = config("API_KEY")
TR_RBD_ID = config("TR_RBD_ID")
TC_RBD_ID = config("TC_RBD_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
from typing import Optional, List
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
import secrets
import redis
from fastapi import HTTPException, Request
import src.config as config
from src.context import get_user_id
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.
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.
FastAPI dependency untuk verifikasi CSRF token berbasis Redis.
Harus dikombinasikan dengan JWTBearer() dependency.
"""
def __init__(
self,
app,
cookie_name: str = "csrf_token",
header_name: str = "X-CSRF-Token",
excluded_paths: Optional[List[str]] = None,
safe_methods: List[str] = ("GET", "HEAD", "OPTIONS", "TRACE")
):
super().__init__(app)
self.cookie_name = cookie_name
self.header_name = header_name
self.excluded_paths = excluded_paths or []
self.safe_methods = safe_methods
async def dispatch(self, request: Request, call_next):
# 1. Skip validation for "safe" methods (GET, HEAD, etc.) or excluded paths
is_safe_method = request.method in self.safe_methods
is_excluded = any(request.url.path.startswith(path) for path in self.excluded_paths)
if not is_safe_method and not is_excluded:
# 2. Extract token from cookie and header
csrf_cookie = request.cookies.get(self.cookie_name)
csrf_header = request.headers.get(self.header_name.lower()) # Starlette lowercase headers
# 3. Validate
if not csrf_cookie:
log.warning(f"CSRF validation failed: Missing cookie '{self.cookie_name}'")
raise HTTPException(
status_code=403,
detail="CSRF validation failed: Missing session cookie."
)
if not csrf_header:
log.warning(f"CSRF validation failed: Missing header '{self.header_name}'")
raise HTTPException(
status_code=403,
detail=f"CSRF validation failed: Missing header '{self.header_name}'."
)
if not secrets.compare_digest(csrf_cookie, csrf_header):
log.warning(f"CSRF validation failed: Token mismatch for path {request.url.path}")
raise HTTPException(
status_code=403,
detail="CSRF validation failed: Token mismatch."
)
# 4. Proceed with the request
response: Response = await call_next(request)
# 5. Ensure the cookie is set if missing (e.g., on first GET request)
if not request.cookies.get(self.cookie_name):
new_token = secrets.token_urlsafe(32)
response.set_cookie(
key=self.cookie_name,
value=new_token,
httponly=False, # Set to False so frontend JS can read it for Double Submit
samesite="lax",
secure=True, # Recommended for production with HTTPS
path="/"
)
return response
# ── Step 1: ambil user_id dari request.state (di-set oleh JWTBearer) ────
user = getattr(request.state, "user", None)
user_id = str(user.user_id) if user else get_user_id()
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
# ── Step 2: baca CSRF token dari request header ───────────────────────────
# Starlette lowercases all header names
csrf_token_header = (
request.headers.get("x-csrf-token")
or request.headers.get("x-xsrf-token")
or request.headers.get("x-csrf-token")
)
if not csrf_token_header:
log.warning(
"[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s",
user_id,
request.client.host if request.client else "unknown",
)
raise HTTPException(status_code=403, detail="CSRF token missing in header")
csrf_token_header = csrf_token_header.strip()
# ── Step 3: bangun Redis key (harus sama dengan format di be-auth) ─────────
current_path = request.url.path
path_hash = hashlib.sha256(current_path.encode()).hexdigest()[:16]
redis_key = f"csrf:{user_id}:{path_hash}"
# ── Step 4: ambil stored token hash dari Redis (DB 0) ────────────────────
stored_token_hash = _redis_auth.get(redis_key)
if not stored_token_hash:
log.warning(
"[CSRF] Token not found in Redis | user_id=%s | path=%s | IP: %s",
user_id,
current_path,
request.client.host if request.client else "unknown",
)
raise HTTPException(status_code=403, detail="CSRF token expired or invalid")
if isinstance(stored_token_hash, bytes):
stored_token_hash = stored_token_hash.decode("utf-8")
# ── 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):
log.warning(
"[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s",
user_id,
current_path,
request.client.host if request.client else "unknown",
)
_redis_auth.delete(redis_key)
raise HTTPException(status_code=403, detail="CSRF token verification failed")
# ── Step 6: hapus token (one-time use) dan lanjutkan ─────────────────────
_redis_auth.delete(redis_key)
log.info(
"[CSRF] Verified & deleted | user_id=%s | path=%s",
user_id,
current_path,
)

@ -1,18 +1,19 @@
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.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (ScopeEquipmentActivityCreate,
ScopeEquipmentActivityPagination,
ScopeEquipmentActivityRead, ScopeEquipmentActivityUpdate)
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]])

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

@ -1,18 +1,20 @@
from typing import List, Optional
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.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (OverhaulActivityCreate, OverhaulActivityPagination,
OverhaulActivityRead, OverhaulActivityUpdate)
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(
@ -109,6 +111,7 @@ async def get_overhaul_equipment(
@router.post(
"/delete/{overhaul_session}/{location_tag}",
response_model=StandardResponse[None],
dependencies=[Depends(csrf_protect)],
)
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)

@ -1,10 +1,11 @@
import re
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from src.auth.service import CurrentUser
from src.csrf_protect import csrf_protect
from src.database.core import DbSession
from src.database.service import CommonParameters
from src.models import StandardResponse
@ -64,7 +65,7 @@ async def get_gantt_spreadsheet(db_session: DbSession):
)
@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):
"""Get all scope pagination."""

@ -11,10 +11,11 @@ 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 src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.csrf_protect import csrf_protect
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))])
@ -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):
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.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.database.service import (CommonParameters, DbSession, search_filter_sort_paginate)
from src.models import StandardResponse
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
router = APIRouter()
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("", response_model=StandardResponse[list])
@ -17,15 +16,13 @@ async def get_sparepart(collector_db_session:CollectorDbSession, db_session: DbS
# return
data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session)
return StandardResponse(
data=data,
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):
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 fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.params import Query
from src.auth.service import CurrentUser
from src.database.core import DbSession, CollectorDbSession
from src.database.service import CommonParameters, search_filter_sort_paginate
from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (MasterEquipmentPagination, ScopeEquipmentCreate,
ScopeEquipmentPagination, ScopeEquipmentRead,
ScopeEquipmentUpdate)
from .service import (create, delete, get_all, get_all_master_equipment, update, get_history_standard_scope_wo_service)
from uuid import UUID
router = APIRouter()
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("", response_model=StandardResponse[ScopeEquipmentPagination])

Loading…
Cancel
Save