Merge pull request 'ปรับปรุง Permission' (#2) from develop into main

Reviewed-on: #2
This commit is contained in:
gitea 2025-11-25 22:44:09 +00:00
commit 03565cd0b7
7 changed files with 132 additions and 104 deletions

View File

@ -7,56 +7,79 @@ from datetime import timedelta
from api.models import InferenceAuditLog from api.models import InferenceAuditLog
from api.serializers.audit_serializer import InferenceAuditLogSerializer 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): class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
""" """
API สำหรบการเขาถ Inference Audit Log และสถรวม API สำหรบการเขาถ Inference Audit Log และสถรวม (รวมการด Summary วย)
""" """
queryset = InferenceAuditLog.objects.all() queryset = InferenceAuditLog.objects.all()
serializer_class = InferenceAuditLogSerializer serializer_class = InferenceAuditLogSerializer
permission_classes = [permissions.IsAuthenticated] # อนุญาตให้เข้าถึงเมื่อล็อกอินแล้ว permission_classes = [permissions.IsAuthenticated]
# ใช้ 'id' เป็นฟิลด์ค้นหา
lookup_field = 'id' lookup_field = 'id'
# ใช้ 'id' เป็นชื่อพารามิเตอร์ใน URL
lookup_url_kwarg = 'id' lookup_url_kwarg = 'id'
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
# บังคับให้ Lookup Key (pk) เป็น String # บังคับให้ Lookup Key (pk) เป็น String
kwargs[self.lookup_url_kwarg] = str(kwargs[self.lookup_url_kwarg]) kwargs[self.lookup_url_kwarg] = str(kwargs[self.lookup_url_kwarg])
return super().retrieve(request, *args, **kwargs) return super().retrieve(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
# คืน Log ล่าสุด 10 รายการ (สำหรับ Recent Events ใน Dashboard) # คืน 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 # Custom Action: ดึงสถิติรวม Dynamic (สำหรับ Viewer, Operator, Admin)
# Endpoint: GET /api/v1/audit/inference-summary/ # 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): def get_summary(self, request):
one_day_ago = timezone.now() - timedelta(hours=24) user = request.user
# 1. คำนวณสถิติรวม (Global Metrics) # 1. ตรวจสอบ Role เพื่อกำหนดขอบเขตข้อมูลที่จะแสดง (Dynamic View)
metrics = self.queryset.filter(timestamp__gte=one_day_ago).aggregate( # ตรวจสอบว่าผู้ใช้เป็น 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'), total_runs=Count('id'),
success_count=Count('id', filter=Q(is_success=True)), 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) total = metrics.get('total_runs', 0)
success = metrics.get('success_count', 0) success = metrics.get('success_count', 0)
success_rate = (success / total) * 100 if total > 0 else 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", "time_window": "24 hours",
"total_runs": total, "total_runs": total,
"success_rate": round(success_rate, 2), "success_rate": round(success_rate, 2),
"avg_latency_ms": round(metrics.get('avg_latency_ms', 0) or 0, 2), "last_logs": InferenceAuditLogSerializer(last_logs, many=True).data
"last_logs": InferenceAuditLogSerializer(self.get_queryset(), 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)

View File

@ -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: The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/ https://docs.djangoproject.com/en/5.2/topics/http/urls/

View File

@ -1,56 +1,57 @@
from rest_framework import permissions 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): allowed_roles = []
# ผู้จัดการคือ 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)."
def has_permission(self, request, view): def has_permission(self, request, view):
# Admin คือ is_superuser user = request.user
return self.is_admin(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): class IsAdmin(RolePermission):
message = "คุณต้องมีสิทธิ์ Admin หรือ Manager." message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (ADMIN Required)."
allowed_roles = [ROLES["ADMIN"]]
def has_permission(self, request, view):
# Admin หรือ Manager (is_staff)
return self.is_admin(request.user) or self.is_manager(request.user)
# ---------------------------------------------------- class IsAdminOrOperator(RolePermission):
# 3. Permission: อนุญาตเฉพาะผู้ใช้ที่ผ่านการยืนยันตัวตนแล้ว (IsAuthenticated) allowed_roles = [ROLES["ADMIN"], ROLES["OPERATOR"]]
# และเป็น Viewer ขึ้นไป
# ----------------------------------------------------
# ไม่จำเป็นต้องสร้างคลาสนี้ถ้าใช้ DRF's IsAuthenticated โดยตรง class IsViewerOrHigher(RolePermission):
# แต่ถ้าต้องการเพิ่ม Logic อื่นๆ ในอนาคต ควรใช้คลาสนี้ allowed_roles = [
class IsAuthenticatedViewer(permissions.BasePermission): ROLES["ADMIN"],
ROLES["OPERATOR"],
ROLES["VIEWER"],
]
class IsAuthenticatedAccess(permissions.IsAuthenticated):
message = "คุณต้องเข้าสู่ระบบก่อน" message = "คุณต้องเข้าสู่ระบบก่อน"
def has_permission(self, request, view):
return request.user.is_authenticated

View File

@ -1,35 +1,37 @@
// src/components/SidebarSubmenu.jsx ( RBAC)
import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon'; import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
// Component canAccess MainLayout
function SidebarSubmenu({ submenu, name, icon, closeMobileSidebar }) { function SidebarSubmenu({ submenu, name, icon, closeMobileSidebar }) {
const location = useLocation(); const location = useLocation();
const [isExpanded, setIsExpanded] = useState(false); 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) => { const canAccess = (requiredRole) => {
if (role === 'admin') return true; // userRole Redux
if (!requiredRole) return true; 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 // Submenu Path Submenu
useEffect(() => { useEffect(() => {
// Path // Path
const isActive = submenu.some( const isActive = submenu.some(
// userRole canAccess
m => canAccess(m.requiredRole) && m.path === location.pathname m => canAccess(m.requiredRole) && m.path === location.pathname
); );
if (isActive) { if (isActive) {
setIsExpanded(true); setIsExpanded(true);
} }
}, [location.pathname, submenu, role]); // role dependency array }, [location.pathname, submenu, userRole]);
return ( return (
<div className='flex flex-col'> <div className='flex flex-col'>

View File

@ -5,6 +5,7 @@ const routes = [
path: '/dashboard', path: '/dashboard',
icon: <FaTachometerAlt className="w-5 h-5 flex-shrink-0" />, icon: <FaTachometerAlt className="w-5 h-5 flex-shrink-0" />,
name: 'แดชบอร์ด/ภาพรวม', name: 'แดชบอร์ด/ภาพรวม',
requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'],
}, },
// ----------------================-- // ----------------================--
@ -14,17 +15,17 @@ const routes = [
path: '', path: '',
icon: <FaFlask className="w-5 h-5 flex-shrink-0" />, icon: <FaFlask className="w-5 h-5 flex-shrink-0" />,
name: 'AI Model Operations', name: 'AI Model Operations',
requiredRole: ['viewer', 'admin', 'manager'], requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'],
submenu: [ submenu: [
{ {
path: '/dashboard/inference-run', path: '/dashboard/inference-run',
name: 'AI Inference (Run)', name: 'AI Inference (Run)',
requiredRole: ['viewer', 'admin', 'manager'], requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'],
}, },
{ {
path: '/dashboard/model-registry', path: '/dashboard/model-registry',
name: 'Model Registry & Control', name: 'Model Registry & Control',
requiredRole: ['manager', 'admin'], requiredRole: ['OPERATOR', 'ADMIN'],
}, },
], ],
}, },
@ -34,7 +35,7 @@ const routes = [
path: '/dashboard/profile', path: '/dashboard/profile',
icon: <FaUserCircle className="w-5 h-5 flex-shrink-0" />, icon: <FaUserCircle className="w-5 h-5 flex-shrink-0" />,
name: 'การจัดการโปรไฟล์', name: 'การจัดการโปรไฟล์',
requiredRole: ['viewer', 'admin', 'manager'], // Role requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'], // Role
}, },
// ---------------------------------- // ----------------------------------
@ -44,13 +45,13 @@ const routes = [
path: '/dashboard/health', path: '/dashboard/health',
icon: <FaHeartbeat className="w-5 h-5 flex-shrink-0" />, icon: <FaHeartbeat className="w-5 h-5 flex-shrink-0" />,
name: 'สถานะระบบ (Health)', name: 'สถานะระบบ (Health)',
requiredRole: ['viewer', 'admin', 'manager'], requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'],
}, },
{ {
path: '/dashboard/guide', path: '/dashboard/guide',
icon: <FaCog className="w-5 h-5 flex-shrink-0" />, icon: <FaCog className="w-5 h-5 flex-shrink-0" />,
name: 'คู่มือการใช้งาน (Guide)', name: 'คู่มือการใช้งาน (Guide)',
requiredRole: ['viewer', 'manager', 'admin'], requiredRole: ['VIEWER', 'OPERATOR', 'ADMIN'],
}, },
]; ];

View File

@ -5,23 +5,24 @@ import { createSlice } from '@reduxjs/toolkit';
// ---------------------------------------------------- // ----------------------------------------------------
const determineRole = (user) => { const determineRole = (user) => {
if (!user || !user.id) { if (!user || !user.id) {
return 'guest'; return 'GUEST';
} }
// 1. ใช้ฟิลด์ 'role' ที่ส่งมาจาก Backend โดยตรง (ถ้ามี) // 1. ใช้ฟิลด์ 'role' ที่ส่งมาจาก Backend โดยตรง
if (user.role) { if (user.role) {
return user.role.toLowerCase(); // เช่น 'ADMIN' -> 'admin' return user.role.toUpperCase();
} }
// 2. ใช้ฟิลด์ is_superuser/is_staff เป็น Fallback // 2. ใช้ฟิลด์ is_superuser/is_staff เป็น Fallback
if (user.is_superuser) { if (user.is_superuser) {
return 'admin'; return 'ADMIN'; //
} }
if (user.is_staff) { if (user.is_staff) {
return 'manager'; return 'OPERATOR';
} }
// ผู้ใช้ทั่วไป // ผู้ใช้ทั่วไป
return 'viewer'; return 'VIEWER';
}; };
// ---------------------------------------------------- // ----------------------------------------------------
@ -29,15 +30,14 @@ const determineRole = (user) => {
// ---------------------------------------------------- // ----------------------------------------------------
const getInitialAuthState = () => { const getInitialAuthState = () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
// อ่าน user object
const user = JSON.parse(localStorage.getItem('user')); const user = JSON.parse(localStorage.getItem('user'));
// ดึง Role
const role = determineRole(user);
return { return {
isAuthenticated: !!token, isAuthenticated: !!token,
user: user || null, user: user || null,
role: role, // คำนวณ Role จาก user object ที่โหลดมา เพื่อให้เป็น Single Source of Truth
role: determineRole(user),
}; };
}; };
@ -49,16 +49,17 @@ const authSlice = createSlice({
reducers: { reducers: {
// Reducer สำหรับการล็อกอินสำเร็จ // Reducer สำหรับการล็อกอินสำเร็จ
loginSuccess: (state, action) => { loginSuccess: (state, action) => {
const user = action.payload.user;
state.isAuthenticated = true; state.isAuthenticated = true;
state.user = action.payload.user; state.user = user;
state.role = action.payload.role; state.role = determineRole(user); // คำนวณ role ทุกครั้ง
}, },
// Reducer สำหรับการออกจากระบบ // Reducer สำหรับการออกจากระบบ
logout: (state) => { logout: (state) => {
state.isAuthenticated = false; state.isAuthenticated = false;
state.user = null; state.user = null;
state.role = 'guest'; state.role = 'GUEST';
// ลบข้อมูลจาก localStorage // ลบข้อมูลจาก localStorage
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
@ -66,10 +67,11 @@ const authSlice = createSlice({
// Reducer สำหรับอัปเดตข้อมูลผู้ใช้ (เช่น หลังอัปเดตโปรไฟล์) // Reducer สำหรับอัปเดตข้อมูลผู้ใช้ (เช่น หลังอัปเดตโปรไฟล์)
updateUser: (state, action) => { updateUser: (state, action) => {
state.user = action.payload.user; const user = action.payload.user;
state.role = action.payload.role; // อัปเดต Role ด้วย (เผื่อมีการอัปเดตสิทธิ์) state.user = user;
localStorage.setItem('user', JSON.stringify(action.payload.user)); state.role = determineRole(user); // คำนวณ Role ใหม่จาก User Object ที่อัปเดต
localStorage.setItem('role', action.payload.role); localStorage.setItem('user', JSON.stringify(user));
} }
}, },
}); });

View File

@ -1,21 +1,20 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useSelector } from 'react-redux';
import axiosClient from './axiosClient'; 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 = () => { export const useInferenceSummary = () => {
const userRole = useSelector((state) => state.auth.userRole); // ดึง Role จาก Redux
return useQuery({ return useQuery({
queryKey: ['inferenceSummary'], queryKey: ['inferenceSummary', userRole], // แยก Cache ตาม Role
queryFn: async () => { queryFn: async () => {
const response = await axiosClient.get('/api/v1/audit/inference-summary/'); const res = await axiosClient.get('/api/v1/audit/summary/');
return response.data; return res.data;
}, },
staleTime: STALE_TIME, staleTime: STALE_TIME,
// ดึงข้อมูลใหม่ทุก 30 วินาทีเพื่ออัปเดตสถิติ Dashboard refetchInterval: 30000, // อัปเดตทุก 30 วินาที
refetchInterval: 30000, enabled: !!userRole, // เรียก API ก็ต่อเมื่อ Role พร้อม
}); });
}; };