From 1d79416a7fd8625490da93ba9302dd0ca08a84f8 Mon Sep 17 00:00:00 2001 From: Flook Date: Wed, 26 Nov 2025 05:32:33 +0700 Subject: [PATCH] =?UTF-8?q?=E0=B8=9B=E0=B8=A3=E0=B8=B1=E0=B8=9A=E0=B8=9B?= =?UTF-8?q?=E0=B8=A3=E0=B8=B8=E0=B8=87=20Permission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/views/audit_viewset.py | 63 ++++++++++++------ backend/core/urls.py | 2 +- backend/permissions/permission_classes.py | 79 ++++++++++++----------- web/src/components/SidebarSubmenu.jsx | 22 ++++--- web/src/config/sidebarRoutes.jsx | 13 ++-- web/src/features/auth/authSlice.js | 36 ++++++----- web/src/services/auditApi.js | 21 +++--- 7 files changed, 132 insertions(+), 104 deletions(-) diff --git a/backend/api/views/audit_viewset.py b/backend/api/views/audit_viewset.py index 48bb8ac..61bd6b0 100644 --- a/backend/api/views/audit_viewset.py +++ b/backend/api/views/audit_viewset.py @@ -7,56 +7,79 @@ from datetime import timedelta from api.models import InferenceAuditLog from api.serializers.audit_serializer import InferenceAuditLogSerializer -from permissions.permission_classes import IsAdminOrManager # ใช้สิทธิ์เดียวกันกับ Model Registry +# นำเข้า Permission ที่จำเป็น +from permissions.permission_classes import IsAdminOrOperator, IsViewerOrHigher class AuditLogViewSet(viewsets.ReadOnlyModelViewSet): """ - API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม + API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม (รวมการดึง Summary ด้วย) """ queryset = InferenceAuditLog.objects.all() serializer_class = InferenceAuditLogSerializer - permission_classes = [permissions.IsAuthenticated] # อนุญาตให้เข้าถึงเมื่อล็อกอินแล้ว + permission_classes = [permissions.IsAuthenticated] - # ใช้ 'id' เป็นฟิลด์ค้นหา lookup_field = 'id' - - # ใช้ 'id' เป็นชื่อพารามิเตอร์ใน URL lookup_url_kwarg = 'id' def retrieve(self, request, *args, **kwargs): # บังคับให้ Lookup Key (pk) เป็น String kwargs[self.lookup_url_kwarg] = str(kwargs[self.lookup_url_kwarg]) - return super().retrieve(request, *args, **kwargs) def get_queryset(self): # คืน Log ล่าสุด 10 รายการ (สำหรับ Recent Events ใน Dashboard) - return self.queryset.select_related('user')[:10] + return self.queryset.select_related('user').order_by('-timestamp')[:10] # 📌 เพิ่ม order_by เพื่อให้ได้ล่าสุดจริง ๆ # ----------------------------------------------- - # Custom Action: ดึงสถิติรวมสำหรับ Dashboard - # Endpoint: GET /api/v1/audit/inference-summary/ + # Custom Action: ดึงสถิติรวม Dynamic (สำหรับ Viewer, Operator, Admin) + # Endpoint: GET /api/v1/audit/summary/ # ----------------------------------------------- - @action(detail=False, methods=['get'], url_path='inference-summary') + @action( + detail=False, + methods=['get'], + url_path='summary', + # อนุญาตให้เข้าถึงสำหรับทุก Role ใน Dashboard + permission_classes=[IsViewerOrHigher] + ) def get_summary(self, request): - one_day_ago = timezone.now() - timedelta(hours=24) + user = request.user - # 1. คำนวณสถิติรวม (Global Metrics) - metrics = self.queryset.filter(timestamp__gte=one_day_ago).aggregate( + # 1. ตรวจสอบ Role เพื่อกำหนดขอบเขตข้อมูลที่จะแสดง (Dynamic View) + # ตรวจสอบว่าผู้ใช้เป็น ADMIN หรือ OPERATOR หรือไม่ + is_high_privilege = user.role in ['ADMIN', 'OPERATOR'] + + # กำหนดขีดจำกัดและข้อมูลที่จะรวม + log_limit = 10 if is_high_privilege else 5 + include_latency = is_high_privilege # Latency เป็นข้อมูลเชิงเทคนิค + + one_day_ago = timezone.now() - timedelta(hours=24) + queryset = self.queryset.filter(timestamp__gte=one_day_ago) + + # 2. คำนวณสถิติพื้นฐาน (ใช้ร่วมกัน) + metrics = queryset.aggregate( total_runs=Count('id'), success_count=Count('id', filter=Q(is_success=True)), - avg_latency_ms=Avg('latency_ms') + # คำนวณ Avg Latency เฉพาะถ้าผู้ใช้มีสิทธิ์สูง + avg_latency_ms=Avg('latency_ms') if include_latency else None ) - # 2. คำนวณ Success Rate + # 3. คำนวณ Success Rate total = metrics.get('total_runs', 0) success = metrics.get('success_count', 0) success_rate = (success / total) * 100 if total > 0 else 0 - return Response({ + # 4. ดึง Log ล่าสุดตามสิทธิ์ + last_logs = self.queryset.select_related('user').order_by('-timestamp')[:log_limit] + + response_data = { "time_window": "24 hours", "total_runs": total, "success_rate": round(success_rate, 2), - "avg_latency_ms": round(metrics.get('avg_latency_ms', 0) or 0, 2), - "last_logs": InferenceAuditLogSerializer(self.get_queryset(), many=True).data - }) \ No newline at end of file + "last_logs": InferenceAuditLogSerializer(last_logs, many=True).data + } + + # 5. เพิ่ม Latency เข้าไปใน Response เฉพาะ Role ที่มีสิทธิ์สูง + if include_latency: + response_data['avg_latency_ms'] = round(metrics.get('avg_latency_ms', 0) or 0, 2) + + return Response(response_data) \ No newline at end of file diff --git a/backend/core/urls.py b/backend/core/urls.py index fef37d9..4056f04 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -1,5 +1,5 @@ """ -URL configuration for cremation_backend project. +URL configuration for backend project. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/5.2/topics/http/urls/ diff --git a/backend/permissions/permission_classes.py b/backend/permissions/permission_classes.py index d67c4e6..a44a478 100644 --- a/backend/permissions/permission_classes.py +++ b/backend/permissions/permission_classes.py @@ -1,56 +1,57 @@ from rest_framework import permissions -from rest_framework.exceptions import PermissionDenied # ---------------------------------------------------- -# Base Class เพื่อใช้ Logic การตรวจสอบสิทธิ์ร่วมกัน +# Global Role Definition # ---------------------------------------------------- -class BaseRolePermission(permissions.BasePermission): +ROLES = { + "ADMIN": "ADMIN", + "OPERATOR": "OPERATOR", + "VIEWER": "VIEWER", +} + + +# ---------------------------------------------------- +# Base Role Permission (Clean Code + Robustness) +# ---------------------------------------------------- +class RolePermission(permissions.BasePermission): """ - Base class สำหรับตรวจสอบสิทธิ์ตาม is_superuser และ is_staff + Base class สำหรับตรวจสอบสิทธิ์ตามฟิลด์ role (ADMIN, OPERATOR, VIEWER) """ - def is_admin(self, user): - return user.is_authenticated and user.is_superuser - def is_manager(self, user): - # ผู้จัดการคือ is_staff แต่ไม่ใช่ superuser - return user.is_authenticated and user.is_staff and not user.is_superuser - - # หรือเพิ่มฟังก์ชัน is_model_owner(user) ถ้าจำเป็น - # ถ้าใช้ฟิลด์ 'role' ที่คุณเพิ่มใน Serializer ควรใช้: - # return user.is_authenticated and getattr(user, 'role', '').upper() == 'ADMIN' - - -# ---------------------------------------------------- -# 1. Permission: อนุญาตเฉพาะ Admin เท่านั้น -# ---------------------------------------------------- -class IsAdmin(BaseRolePermission): - message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (Admin Required)." + allowed_roles = [] def has_permission(self, request, view): - # Admin คือ is_superuser - return self.is_admin(request.user) + user = request.user + + if not user or not user.is_authenticated: + return False + + # กรณีที่ไม่มี attribute ชื่อ role ก็จะไม่ทำให้เกิด AttributeError + user_role = getattr(user, 'role', '').upper() + + return user_role in self.allowed_roles # ---------------------------------------------------- -# 2. Permission: อนุญาตเฉพาะ Admin หรือ Manager เท่านั้น +# Specific Permission Classes # ---------------------------------------------------- -class IsAdminOrManager(BaseRolePermission): - message = "คุณต้องมีสิทธิ์ Admin หรือ Manager." - - def has_permission(self, request, view): - # Admin หรือ Manager (is_staff) - return self.is_admin(request.user) or self.is_manager(request.user) +class IsAdmin(RolePermission): + message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (ADMIN Required)." + allowed_roles = [ROLES["ADMIN"]] -# ---------------------------------------------------- -# 3. Permission: อนุญาตเฉพาะผู้ใช้ที่ผ่านการยืนยันตัวตนแล้ว (IsAuthenticated) -# และเป็น Viewer ขึ้นไป -# ---------------------------------------------------- -# ไม่จำเป็นต้องสร้างคลาสนี้ถ้าใช้ DRF's IsAuthenticated โดยตรง -# แต่ถ้าต้องการเพิ่ม Logic อื่นๆ ในอนาคต ควรใช้คลาสนี้ -class IsAuthenticatedViewer(permissions.BasePermission): +class IsAdminOrOperator(RolePermission): + allowed_roles = [ROLES["ADMIN"], ROLES["OPERATOR"]] + + +class IsViewerOrHigher(RolePermission): + allowed_roles = [ + ROLES["ADMIN"], + ROLES["OPERATOR"], + ROLES["VIEWER"], + ] + + +class IsAuthenticatedAccess(permissions.IsAuthenticated): message = "คุณต้องเข้าสู่ระบบก่อน" - - def has_permission(self, request, view): - return request.user.is_authenticated \ No newline at end of file diff --git a/web/src/components/SidebarSubmenu.jsx b/web/src/components/SidebarSubmenu.jsx index 9b78ccb..b46f8e2 100644 --- a/web/src/components/SidebarSubmenu.jsx +++ b/web/src/components/SidebarSubmenu.jsx @@ -1,35 +1,37 @@ -// src/components/SidebarSubmenu.jsx (ปรับปรุงสำหรับ RBAC) - import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon'; import { useEffect, useState } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux'; -// Component นี้คาดหวังว่าฟังก์ชัน canAccess จะถูกส่งมาจาก MainLayout หรือสร้างขึ้นใหม่ - function SidebarSubmenu({ submenu, name, icon, closeMobileSidebar }) { const location = useLocation(); const [isExpanded, setIsExpanded] = useState(false); - const role = useSelector(state => state.auth.role); // ดึง Role จาก Redux + // ดึง Role จาก Redux + const userRole = useSelector(state => state.auth.role); - // ฟังก์ชันตรวจสอบสิทธิ์ (ควรมาจาก MainLayout แต่เราจะใช้ Logic พื้นฐาน) + // ฟังก์ชันตรวจสอบสิทธิ์ const canAccess = (requiredRole) => { - if (role === 'admin') return true; + // userRole ที่มาจาก Redux ควรเป็นตัวพิมพ์ใหญ่แล้ว if (!requiredRole) return true; - const allowedRoles = Array.isArray(requiredRole) ? requiredRole : [requiredRole]; - return allowedRoles.includes(role); + + const allowedRoles = Array.isArray(requiredRole) + ? requiredRole + : [requiredRole]; + + return allowedRoles.includes(userRole); }; // เปิด Submenu หาก Path ปัจจุบันอยู่ใน Submenu นั้น useEffect(() => { // ตรวจสอบว่ามีเมนูย่อยใดที่ผู้ใช้เข้าถึงได้และเป็น Path ปัจจุบันหรือไม่ const isActive = submenu.some( + // ใช้ userRole ใน canAccess m => canAccess(m.requiredRole) && m.path === location.pathname ); if (isActive) { setIsExpanded(true); } - }, [location.pathname, submenu, role]); // เพิ่ม role ใน dependency array + }, [location.pathname, submenu, userRole]); return (
diff --git a/web/src/config/sidebarRoutes.jsx b/web/src/config/sidebarRoutes.jsx index 32b1a86..453bf80 100644 --- a/web/src/config/sidebarRoutes.jsx +++ b/web/src/config/sidebarRoutes.jsx @@ -5,6 +5,7 @@ const routes = [ path: '/dashboard', icon: , name: 'แดชบอร์ด/ภาพรวม', + requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'], }, // ----------------================-- @@ -14,17 +15,17 @@ const routes = [ path: '', icon: , name: 'AI Model Operations', - requiredRole: ['viewer', 'admin', 'manager'], + requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'], submenu: [ { path: '/dashboard/inference-run', name: 'AI Inference (Run)', - requiredRole: ['viewer', 'admin', 'manager'], + requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'], }, { path: '/dashboard/model-registry', name: 'Model Registry & Control', - requiredRole: ['manager', 'admin'], + requiredRole: ['OPERATOR', 'ADMIN'], }, ], }, @@ -34,7 +35,7 @@ const routes = [ path: '/dashboard/profile', icon: , name: 'การจัดการโปรไฟล์', - requiredRole: ['viewer', 'admin', 'manager'], // ทุก Role ควรเข้าถึงได้ + requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'], // ทุก Role ควรเข้าถึงได้ }, // ---------------------------------- @@ -44,13 +45,13 @@ const routes = [ path: '/dashboard/health', icon: , name: 'สถานะระบบ (Health)', - requiredRole: ['viewer', 'admin', 'manager'], + requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'], }, { path: '/dashboard/guide', icon: , name: 'คู่มือการใช้งาน (Guide)', - requiredRole: ['viewer', 'manager', 'admin'], + requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'], }, ]; diff --git a/web/src/features/auth/authSlice.js b/web/src/features/auth/authSlice.js index 60625fd..08225f9 100644 --- a/web/src/features/auth/authSlice.js +++ b/web/src/features/auth/authSlice.js @@ -5,23 +5,24 @@ import { createSlice } from '@reduxjs/toolkit'; // ---------------------------------------------------- const determineRole = (user) => { if (!user || !user.id) { - return 'guest'; + return 'GUEST'; } - // 1. ใช้ฟิลด์ 'role' ที่ส่งมาจาก Backend โดยตรง (ถ้ามี) + // 1. ใช้ฟิลด์ 'role' ที่ส่งมาจาก Backend โดยตรง if (user.role) { - return user.role.toLowerCase(); // เช่น 'ADMIN' -> 'admin' + return user.role.toUpperCase(); } // 2. ใช้ฟิลด์ is_superuser/is_staff เป็น Fallback if (user.is_superuser) { - return 'admin'; + return 'ADMIN'; // } if (user.is_staff) { - return 'manager'; + return 'OPERATOR'; } + // ผู้ใช้ทั่วไป - return 'viewer'; + return 'VIEWER'; }; // ---------------------------------------------------- @@ -29,15 +30,14 @@ const determineRole = (user) => { // ---------------------------------------------------- const getInitialAuthState = () => { const token = localStorage.getItem('token'); + // อ่าน user object const user = JSON.parse(localStorage.getItem('user')); - // ดึง Role - const role = determineRole(user); - return { isAuthenticated: !!token, user: user || null, - role: role, + // คำนวณ Role จาก user object ที่โหลดมา เพื่อให้เป็น Single Source of Truth + role: determineRole(user), }; }; @@ -49,16 +49,17 @@ const authSlice = createSlice({ reducers: { // Reducer สำหรับการล็อกอินสำเร็จ loginSuccess: (state, action) => { + const user = action.payload.user; state.isAuthenticated = true; - state.user = action.payload.user; - state.role = action.payload.role; + state.user = user; + state.role = determineRole(user); // คำนวณ role ทุกครั้ง }, // Reducer สำหรับการออกจากระบบ logout: (state) => { state.isAuthenticated = false; state.user = null; - state.role = 'guest'; + state.role = 'GUEST'; // ลบข้อมูลจาก localStorage localStorage.removeItem('token'); localStorage.removeItem('user'); @@ -66,10 +67,11 @@ const authSlice = createSlice({ // Reducer สำหรับอัปเดตข้อมูลผู้ใช้ (เช่น หลังอัปเดตโปรไฟล์) updateUser: (state, action) => { - state.user = action.payload.user; - state.role = action.payload.role; // อัปเดต Role ด้วย (เผื่อมีการอัปเดตสิทธิ์) - localStorage.setItem('user', JSON.stringify(action.payload.user)); - localStorage.setItem('role', action.payload.role); + const user = action.payload.user; + state.user = user; + state.role = determineRole(user); // คำนวณ Role ใหม่จาก User Object ที่อัปเดต + localStorage.setItem('user', JSON.stringify(user)); + } }, }); diff --git a/web/src/services/auditApi.js b/web/src/services/auditApi.js index 079d331..cde2508 100644 --- a/web/src/services/auditApi.js +++ b/web/src/services/auditApi.js @@ -1,21 +1,20 @@ import { useQuery } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; import axiosClient from './axiosClient'; -const STALE_TIME = 60000; // 1 นาที +const STALE_TIME = 60000; -/** - * Hook สำหรับดึงสรุปสถิติ Inference (Total Runs, Success Rate, Avg Latency) - * Endpoint: GET /api/v1/audit/inference-summary/ - */ export const useInferenceSummary = () => { + const userRole = useSelector((state) => state.auth.userRole); // ดึง Role จาก Redux + return useQuery({ - queryKey: ['inferenceSummary'], + queryKey: ['inferenceSummary', userRole], // แยก Cache ตาม Role queryFn: async () => { - const response = await axiosClient.get('/api/v1/audit/inference-summary/'); - return response.data; + const res = await axiosClient.get('/api/v1/audit/summary/'); + return res.data; }, staleTime: STALE_TIME, - // ดึงข้อมูลใหม่ทุก 30 วินาทีเพื่ออัปเดตสถิติ Dashboard - refetchInterval: 30000, + refetchInterval: 30000, // อัปเดตทุก 30 วินาที + enabled: !!userRole, // เรียก API ก็ต่อเมื่อ Role พร้อม }); -}; \ No newline at end of file +};