feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
Major Features: - ✅ Multi-tenant architecture (tenant isolation) - ✅ Employee CRUD with lifecycle management (onboarding/offboarding) - ✅ Department tree structure with email domain management - ✅ Company info management (single-record editing) - ✅ System functions CRUD (permission management) - ✅ Email account management (multi-account per employee) - ✅ Keycloak SSO integration (auth.lab.taipei) - ✅ Redis session storage (10.1.0.254:6379) - Solves Cookie 4KB limitation - Cross-system session sharing - Sliding expiration (8 hours) - Automatic token refresh Technical Stack: Backend: - FastAPI + SQLAlchemy - PostgreSQL 16 (10.1.0.20:5433) - Keycloak Admin API integration - Docker Mailserver integration (SSH) - Alembic migrations Frontend: - Next.js 14 (App Router) - NextAuth 4 with Keycloak Provider - Redis session storage (ioredis) - Tailwind CSS Infrastructure: - Redis 7 (10.1.0.254:6379) - Session + Cache - Keycloak 26.1.0 (auth.lab.taipei) - Docker Mailserver (10.1.0.254) Architecture Highlights: - Session管理由 Keycloak + Redis 統一控制 - 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session - Token 自動刷新,異質服務整合 - 未來可無縫遷移到雲端 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
542
backend/app/api/v1/permissions.py
Normal file
542
backend/app/api/v1/permissions.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
系統權限管理 API
|
||||
管理員工對各系統 (Gitea, Portainer, Traefik, Keycloak) 的存取權限
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.employee import Employee
|
||||
from app.models.permission import Permission
|
||||
from app.schemas.permission import (
|
||||
PermissionCreate,
|
||||
PermissionUpdate,
|
||||
PermissionResponse,
|
||||
PermissionListItem,
|
||||
PermissionBatchCreate,
|
||||
PermissionFilter,
|
||||
VALID_SYSTEMS,
|
||||
VALID_ACCESS_LEVELS,
|
||||
)
|
||||
from app.schemas.base import PaginationParams, PaginatedResponse
|
||||
from app.schemas.response import SuccessResponse, MessageResponse
|
||||
from app.api.deps import get_pagination_params
|
||||
from app.services.audit_service import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> int:
|
||||
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token 取得)"""
|
||||
return 1
|
||||
|
||||
|
||||
@router.get("/", response_model=PaginatedResponse)
|
||||
def get_permissions(
|
||||
db: Session = Depends(get_db),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
filter_params: PermissionFilter = Depends(),
|
||||
):
|
||||
"""
|
||||
獲取權限列表
|
||||
|
||||
支援:
|
||||
- 分頁
|
||||
- 員工篩選
|
||||
- 系統名稱篩選
|
||||
- 存取層級篩選
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
query = db.query(Permission).filter(Permission.tenant_id == tenant_id)
|
||||
|
||||
# 員工篩選
|
||||
if filter_params.employee_id:
|
||||
query = query.filter(Permission.employee_id == filter_params.employee_id)
|
||||
|
||||
# 系統名稱篩選
|
||||
if filter_params.system_name:
|
||||
query = query.filter(Permission.system_name == filter_params.system_name)
|
||||
|
||||
# 存取層級篩選
|
||||
if filter_params.access_level:
|
||||
query = query.filter(Permission.access_level == filter_params.access_level)
|
||||
|
||||
# 總數
|
||||
total = query.count()
|
||||
|
||||
# 分頁
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
permissions = (
|
||||
query.options(joinedload(Permission.employee))
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 計算總頁數
|
||||
total_pages = (total + pagination.page_size - 1) // pagination.page_size
|
||||
|
||||
# 組裝回應資料
|
||||
items = []
|
||||
for perm in permissions:
|
||||
item = PermissionListItem.model_validate(perm)
|
||||
item.employee_name = perm.employee.legal_name if perm.employee else None
|
||||
item.employee_number = perm.employee.employee_id if perm.employee else None
|
||||
items.append(item)
|
||||
|
||||
return PaginatedResponse(
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
total_pages=total_pages,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/systems", response_model=dict)
|
||||
def get_available_systems_route():
|
||||
"""
|
||||
取得所有可授權的系統列表 (必須在 /{permission_id} 之前定義)
|
||||
"""
|
||||
return {
|
||||
"systems": VALID_SYSTEMS,
|
||||
"access_levels": VALID_ACCESS_LEVELS,
|
||||
"system_descriptions": {
|
||||
"gitea": "Git 程式碼託管系統",
|
||||
"portainer": "Docker 容器管理系統",
|
||||
"traefik": "反向代理與路由系統",
|
||||
"keycloak": "SSO 身份認證系統",
|
||||
},
|
||||
"access_level_descriptions": {
|
||||
"admin": "完整管理權限",
|
||||
"user": "一般使用者權限",
|
||||
"readonly": "唯讀權限",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{permission_id}", response_model=PermissionResponse)
|
||||
def get_permission(
|
||||
permission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
獲取權限詳情
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
permission = (
|
||||
db.query(Permission)
|
||||
.options(
|
||||
joinedload(Permission.employee),
|
||||
joinedload(Permission.granter),
|
||||
)
|
||||
.filter(
|
||||
Permission.id == permission_id,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Permission with id {permission_id} not found",
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = PermissionResponse.model_validate(permission)
|
||||
response.employee_name = permission.employee.legal_name if permission.employee else None
|
||||
response.employee_number = permission.employee.employee_id if permission.employee else None
|
||||
response.granted_by_name = permission.granter.legal_name if permission.granter else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/", response_model=PermissionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_permission(
|
||||
permission_data: PermissionCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
創建權限
|
||||
|
||||
注意:
|
||||
- 員工必須存在
|
||||
- 每個員工對每個系統只能有一個權限 (unique constraint)
|
||||
- 系統名稱: gitea, portainer, traefik, keycloak
|
||||
- 存取層級: admin, user, readonly
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == permission_data.employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {permission_data.employee_id} not found",
|
||||
)
|
||||
|
||||
# 檢查是否已有該系統的權限
|
||||
existing = db.query(Permission).filter(
|
||||
and_(
|
||||
Permission.employee_id == permission_data.employee_id,
|
||||
Permission.system_name == permission_data.system_name,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Permission for system '{permission_data.system_name}' already exists for this employee",
|
||||
)
|
||||
|
||||
# 檢查授予人是否存在 (如果有提供)
|
||||
if permission_data.granted_by:
|
||||
granter = db.query(Employee).filter(
|
||||
Employee.id == permission_data.granted_by,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not granter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Granter with id {permission_data.granted_by} not found",
|
||||
)
|
||||
|
||||
# 創建權限
|
||||
permission = Permission(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=permission_data.employee_id,
|
||||
system_name=permission_data.system_name,
|
||||
access_level=permission_data.access_level,
|
||||
granted_by=permission_data.granted_by,
|
||||
)
|
||||
|
||||
db.add(permission)
|
||||
db.commit()
|
||||
db.refresh(permission)
|
||||
|
||||
# 重新載入關聯資料
|
||||
db.refresh(permission)
|
||||
permission = (
|
||||
db.query(Permission)
|
||||
.options(
|
||||
joinedload(Permission.employee),
|
||||
joinedload(Permission.granter),
|
||||
)
|
||||
.filter(Permission.id == permission.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="create_permission",
|
||||
resource_type="permission",
|
||||
resource_id=permission.id,
|
||||
details={
|
||||
"employee_id": permission.employee_id,
|
||||
"system_name": permission.system_name,
|
||||
"access_level": permission.access_level,
|
||||
"granted_by": permission.granted_by,
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = PermissionResponse.model_validate(permission)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_number = employee.employee_id
|
||||
response.granted_by_name = permission.granter.legal_name if permission.granter else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/{permission_id}", response_model=PermissionResponse)
|
||||
def update_permission(
|
||||
permission_id: int,
|
||||
permission_data: PermissionUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
更新權限
|
||||
|
||||
可更新:
|
||||
- 存取層級 (admin, user, readonly)
|
||||
- 授予人
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
permission = (
|
||||
db.query(Permission)
|
||||
.options(
|
||||
joinedload(Permission.employee),
|
||||
joinedload(Permission.granter),
|
||||
)
|
||||
.filter(
|
||||
Permission.id == permission_id,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Permission with id {permission_id} not found",
|
||||
)
|
||||
|
||||
# 檢查授予人是否存在 (如果有提供)
|
||||
if permission_data.granted_by:
|
||||
granter = db.query(Employee).filter(
|
||||
Employee.id == permission_data.granted_by,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not granter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Granter with id {permission_data.granted_by} not found",
|
||||
)
|
||||
|
||||
# 記錄變更前的值
|
||||
old_access_level = permission.access_level
|
||||
old_granted_by = permission.granted_by
|
||||
|
||||
# 更新欄位
|
||||
permission.access_level = permission_data.access_level
|
||||
if permission_data.granted_by is not None:
|
||||
permission.granted_by = permission_data.granted_by
|
||||
|
||||
db.commit()
|
||||
db.refresh(permission)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="update_permission",
|
||||
resource_type="permission",
|
||||
resource_id=permission.id,
|
||||
details={
|
||||
"employee_id": permission.employee_id,
|
||||
"system_name": permission.system_name,
|
||||
"changes": {
|
||||
"access_level": {"from": old_access_level, "to": permission.access_level},
|
||||
"granted_by": {"from": old_granted_by, "to": permission.granted_by},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
response = PermissionResponse.model_validate(permission)
|
||||
response.employee_name = permission.employee.legal_name if permission.employee else None
|
||||
response.employee_number = permission.employee.employee_id if permission.employee else None
|
||||
response.granted_by_name = permission.granter.legal_name if permission.granter else None
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{permission_id}", response_model=MessageResponse)
|
||||
def delete_permission(
|
||||
permission_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
刪除權限
|
||||
|
||||
撤銷員工對某系統的存取權限
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
permission = db.query(Permission).filter(
|
||||
Permission.id == permission_id,
|
||||
Permission.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Permission with id {permission_id} not found",
|
||||
)
|
||||
|
||||
# 記錄審計日誌 (在刪除前)
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="delete_permission",
|
||||
resource_type="permission",
|
||||
resource_id=permission.id,
|
||||
details={
|
||||
"employee_id": permission.employee_id,
|
||||
"system_name": permission.system_name,
|
||||
"access_level": permission.access_level,
|
||||
},
|
||||
)
|
||||
|
||||
# 刪除權限
|
||||
db.delete(permission)
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(
|
||||
message=f"Permission for system {permission.system_name} has been revoked"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}/permissions", response_model=List[PermissionResponse])
|
||||
def get_employee_permissions(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
取得員工的所有系統權限
|
||||
|
||||
回傳該員工可以存取的所有系統及其權限層級
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found",
|
||||
)
|
||||
|
||||
# 查詢員工的所有權限
|
||||
permissions = (
|
||||
db.query(Permission)
|
||||
.options(joinedload(Permission.granter))
|
||||
.filter(
|
||||
Permission.employee_id == employee_id,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
result = []
|
||||
for perm in permissions:
|
||||
response = PermissionResponse.model_validate(perm)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_number = employee.employee_id
|
||||
response.granted_by_name = perm.granter.legal_name if perm.granter else None
|
||||
result.append(response)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[PermissionResponse], status_code=status.HTTP_201_CREATED)
|
||||
def create_permissions_batch(
|
||||
batch_data: PermissionBatchCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批量創建權限
|
||||
|
||||
一次為一個員工授予多個系統的權限
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
# 檢查員工是否存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == batch_data.employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {batch_data.employee_id} not found",
|
||||
)
|
||||
|
||||
# 檢查授予人是否存在 (如果有提供)
|
||||
granter = None
|
||||
if batch_data.granted_by:
|
||||
granter = db.query(Employee).filter(
|
||||
Employee.id == batch_data.granted_by,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not granter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Granter with id {batch_data.granted_by} not found",
|
||||
)
|
||||
|
||||
# 創建權限列表
|
||||
created_permissions = []
|
||||
for perm_data in batch_data.permissions:
|
||||
# 檢查是否已有該系統的權限
|
||||
existing = db.query(Permission).filter(
|
||||
and_(
|
||||
Permission.employee_id == batch_data.employee_id,
|
||||
Permission.system_name == perm_data.system_name,
|
||||
Permission.tenant_id == tenant_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 跳過已存在的權限
|
||||
continue
|
||||
|
||||
# 創建權限
|
||||
permission = Permission(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=batch_data.employee_id,
|
||||
system_name=perm_data.system_name,
|
||||
access_level=perm_data.access_level,
|
||||
granted_by=batch_data.granted_by,
|
||||
)
|
||||
|
||||
db.add(permission)
|
||||
created_permissions.append(permission)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 刷新所有創建的權限
|
||||
for perm in created_permissions:
|
||||
db.refresh(perm)
|
||||
|
||||
# 記錄審計日誌
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="create_permissions_batch",
|
||||
resource_type="permission",
|
||||
resource_id=batch_data.employee_id,
|
||||
details={
|
||||
"employee_id": batch_data.employee_id,
|
||||
"granted_by": batch_data.granted_by,
|
||||
"permissions": [
|
||||
{
|
||||
"system_name": perm.system_name,
|
||||
"access_level": perm.access_level,
|
||||
}
|
||||
for perm in created_permissions
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# 組裝回應資料
|
||||
result = []
|
||||
for perm in created_permissions:
|
||||
response = PermissionResponse.model_validate(perm)
|
||||
response.employee_name = employee.legal_name
|
||||
response.employee_number = employee.employee_id
|
||||
response.granted_by_name = granter.legal_name if granter else None
|
||||
result.append(response)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user