# MCP Internal 權限系統設計
## 設計目標
實作 TEST_PLAN.md 中定義的三層權限控制:
1. 身份驗證(已完成)
2. 角色權限(部分完成)
3. 記錄層級權限(待實作)
---
## 權限規則摘要
### 核心原則
1. **Admin 全權限** → 可讀取/編輯所有物件,包含獎金
2. **獎金保密** → 除 Admin 外,只有負責人可讀取(助理也受限)
3. **助理全編輯** → 可編輯所有物件,但獎金除外
4. **商機成員原則** → 所有人只要是該商機成員,就可以讀取+編輯+作廢該商機相關物件
5. **工務額外編輯** → 工務對工程業務流程 + 統包管理的所有物件有完整編輯權限
### 權限判斷流程
```
用戶要操作某物件
│
▼
是 Admin? ──Yes──▶ ✅ 允許所有操作(包含獎金)
│
No
▼
是獎金/獎金明細? ──Yes──▶ 是負責人? ──Yes──▶ ✅ 允許讀取
│ │
No No ──▶ ❌ 拒絕(包含助理)
▼
是助理嗎? ──Yes──▶ ✅ 允許所有操作(獎金除外)
│
No
▼
是工程/統包物件? ──Yes──▶ 是工務? ──Yes──▶ ✅ 允許編輯全部
│ │
│ No
│ ▼
▼ 是商機成員? ──Yes──▶ ✅ 允許
是商機成員? ──Yes──▶ ✅ 允許讀取/編輯/作廢 │
│ No ──▶ ❌ 拒絕
No
▼
❌ 拒絕存取
```
---
## 物件分類定義
### 分類列表
```typescript
export enum ObjectCategory {
PURE_MATERIAL = 'pure_material', // 純料業務流程
ENGINEERING = 'engineering', // 工程業務流程
PACKAGE = 'package', // 統包管理
PERSONNEL = 'personnel', // 元心人工作
CUSTOMER = 'customer', // 客戶管理
OPPORTUNITY = 'opportunity', // 商機管理
BONUS = 'bonus', // 獎金(特殊處理)
}
```
### 物件對應表
```typescript
export const OBJECT_CATEGORY_MAP: Record<string, ObjectCategory> = {
// === 純料業務流程 ===
'purchase_plan__c': ObjectCategory.PURE_MATERIAL,
'purchase_plan_detail__c': ObjectCategory.PURE_MATERIAL,
'goods_delivery__c': ObjectCategory.PURE_MATERIAL,
'goods_delivery_detail__c': ObjectCategory.PURE_MATERIAL,
'return_order__c': ObjectCategory.PURE_MATERIAL,
'return_order_product__c': ObjectCategory.PURE_MATERIAL,
'miscellaneous_expense__c': ObjectCategory.PURE_MATERIAL,
'miscellaneous_expense_detail__c': ObjectCategory.PURE_MATERIAL,
'refund__c': ObjectCategory.PURE_MATERIAL,
'heying_logistics__c': ObjectCategory.PURE_MATERIAL,
// === 工程業務流程 ===
'spc_work_order__c': ObjectCategory.ENGINEERING,
'spc_repair_order__c': ObjectCategory.ENGINEERING,
'spc_repair_claim__c': ObjectCategory.ENGINEERING,
'spc_repair_claim_detail__c': ObjectCategory.ENGINEERING,
'object_8W9cb__c': ObjectCategory.ENGINEERING, // 案場(SPC)
'object_50HJ8__c': ObjectCategory.ENGINEERING, // 工地師父
'progress_announcement__c': ObjectCategory.ENGINEERING,
'engineering_project_role__c': ObjectCategory.ENGINEERING,
// === 統包管理 ===
'construction_unit__c': ObjectCategory.PACKAGE,
'space__c': ObjectCategory.PACKAGE,
'construction__c': ObjectCategory.PACKAGE,
'work_procedure__c': ObjectCategory.PACKAGE,
'work_category__c': ObjectCategory.PACKAGE,
'work_item__c': ObjectCategory.PACKAGE,
'cabinet_installation__c': ObjectCategory.PACKAGE,
// === 元心人工作 ===
'daily_log__c': ObjectCategory.PERSONNEL,
'follow_up_record__c': ObjectCategory.PERSONNEL,
'schedule__c': ObjectCategory.PERSONNEL,
'employee_application__c': ObjectCategory.PERSONNEL,
'PersonnelObj': ObjectCategory.PERSONNEL,
'field_work__c': ObjectCategory.PERSONNEL,
'cleaning_work__c': ObjectCategory.PERSONNEL,
// === 獎金(特殊處理)===
'bonus__c': ObjectCategory.BONUS,
'bonus_detail__c': ObjectCategory.BONUS,
// === 客戶管理 ===
'AccountObj': ObjectCategory.CUSTOMER,
'ContactObj': ObjectCategory.CUSTOMER,
'public_pool__c': ObjectCategory.CUSTOMER,
'partner__c': ObjectCategory.CUSTOMER,
// === 商機管理 ===
'NewOpportunityObj': ObjectCategory.OPPORTUNITY,
'opportunity_detail__c': ObjectCategory.OPPORTUNITY,
'opportunity_contact__c': ObjectCategory.OPPORTUNITY,
'quotation__c': ObjectCategory.OPPORTUNITY,
'quotation_detail__c': ObjectCategory.OPPORTUNITY,
}
```
### 工務可編輯物件
```typescript
export const CONSTRUCTION_EDITABLE_CATEGORIES = [
ObjectCategory.ENGINEERING,
ObjectCategory.PACKAGE,
]
```
---
## 權限服務設計
### 檔案結構
```
packages/cloud-backend/src/
├── services/
│ ├── permission/
│ │ ├── index.ts # 主入口
│ │ ├── types.ts # 型別定義
│ │ ├── object-category.ts # 物件分類
│ │ ├── permission-checker.ts # 權限檢查核心
│ │ ├── opportunity-member.ts # 商機成員檢查
│ │ └── bonus-owner.ts # 獎金負責人檢查
│ └── fxcrm/
│ ├── personnel.ts # 已有
│ └── opportunity.ts # 新增:商機查詢
```
### 核心介面
```typescript
// types.ts
export type Permission = 'admin' | 'assistant' | 'construction' | 'sales' | 'viewer'
export type ActionType = 'read' | 'create' | 'update' | 'delete' | 'invalid'
export interface PermissionCheckRequest {
userFsuid: string
userPermission: Permission
objectApiName: string
action: ActionType
recordId?: string // 記錄 ID(用於檢查負責人/商機成員)
opportunityId?: string // 關聯的商機 ID
}
export interface PermissionCheckResult {
allowed: boolean
reason?: string // 拒絕原因
recordFilter?: any[] // 記錄過濾條件(用於 Odoo domain)
}
```
### 權限檢查核心邏輯
```typescript
// permission-checker.ts
import { ObjectCategory, getObjectCategory, isConstructionEditable } from './object-category.js'
import { isOpportunityMember } from './opportunity-member.js'
import { isBonusOwner } from './bonus-owner.js'
import type { PermissionCheckRequest, PermissionCheckResult } from './types.js'
export async function checkPermission(
request: PermissionCheckRequest
): Promise<PermissionCheckResult> {
const { userFsuid, userPermission, objectApiName, action, recordId, opportunityId } = request
// 1. Admin 全權限
if (userPermission === 'admin') {
return { allowed: true }
}
// 2. 獎金物件特殊處理
const category = getObjectCategory(objectApiName)
if (category === ObjectCategory.BONUS) {
// 獎金只有負責人可讀,其他操作禁止(除了 admin)
if (action !== 'read') {
return { allowed: false, reason: '獎金只能由 Admin 編輯' }
}
if (!recordId) {
// 查詢時返回過濾條件,只顯示自己是負責人的
return {
allowed: true,
recordFilter: [['owner_id', '=', userFsuid]]
}
}
const isOwner = await isBonusOwner(userFsuid, recordId)
if (!isOwner) {
return { allowed: false, reason: '只能讀取自己是負責人的獎金' }
}
return { allowed: true }
}
// 3. 助理全編輯(獎金除外,上面已處理)
if (userPermission === 'assistant') {
return { allowed: true }
}
// 4. 工務對工程/統包物件有完整編輯權限
if (userPermission === 'construction' && isConstructionEditable(objectApiName)) {
return { allowed: true }
}
// 5. 商機成員檢查
if (opportunityId) {
const isMember = await isOpportunityMember(userFsuid, opportunityId)
if (isMember) {
return { allowed: true }
}
return { allowed: false, reason: '您不是該商機的成員' }
}
// 6. 沒有提供商機 ID,需要查詢記錄關聯的商機
if (recordId) {
const relatedOpportunityId = await getRelatedOpportunityId(objectApiName, recordId)
if (relatedOpportunityId) {
const isMember = await isOpportunityMember(userFsuid, relatedOpportunityId)
if (isMember) {
return { allowed: true }
}
return { allowed: false, reason: '您不是該商機的成員' }
}
}
// 7. 查詢操作:返回過濾條件
if (action === 'read' && !recordId) {
const memberOpportunityIds = await getUserOpportunityIds(userFsuid)
if (memberOpportunityIds.length === 0) {
return { allowed: false, reason: '您沒有參與任何商機' }
}
return {
allowed: true,
recordFilter: [['opportunity_id', 'in', memberOpportunityIds]]
}
}
// 8. 預設拒絕
return { allowed: false, reason: '權限不足' }
}
```
### 商機成員檢查
```typescript
// opportunity-member.ts
import { queryFxcrm } from '../fxcrm/client.js'
// 快取商機成員(5 分鐘)
const memberCache = new Map<string, { members: string[], expiry: number }>()
const CACHE_TTL = 5 * 60 * 1000
export async function isOpportunityMember(
userFsuid: string,
opportunityId: string
): Promise<boolean> {
const members = await getOpportunityMembers(opportunityId)
return members.includes(userFsuid)
}
export async function getOpportunityMembers(opportunityId: string): Promise<string[]> {
// 檢查快取
const cached = memberCache.get(opportunityId)
if (cached && cached.expiry > Date.now()) {
return cached.members
}
// 查詢 FX-CRM
const results = await queryFxcrm('NewOpportunityObj', [
{ field_name: '_id', field_values: [opportunityId], operator: 'EQ' }
])
if (results.length === 0) {
return []
}
const opportunity = results[0]
const members: string[] = []
// owner 也算成員
if (opportunity.owner) {
members.push(opportunity.owner)
}
// team_member__c 是商機成員欄位
if (opportunity.team_member__c) {
if (Array.isArray(opportunity.team_member__c)) {
members.push(...opportunity.team_member__c)
} else if (typeof opportunity.team_member__c === 'string') {
members.push(opportunity.team_member__c)
}
}
// 存入快取
memberCache.set(opportunityId, {
members: [...new Set(members)], // 去重
expiry: Date.now() + CACHE_TTL
})
return members
}
export async function getUserOpportunityIds(userFsuid: string): Promise<string[]> {
// 查詢用戶是成員的所有商機
const results = await queryFxcrm('NewOpportunityObj', [
{ field_name: 'team_member__c', field_values: [userFsuid], operator: 'CONTAIN' }
])
const ownerResults = await queryFxcrm('NewOpportunityObj', [
{ field_name: 'owner', field_values: [userFsuid], operator: 'EQ' }
])
const allResults = [...results, ...ownerResults]
const ids = allResults.map(r => r._id)
return [...new Set(ids)] // 去重
}
```
### 獎金負責人檢查
```typescript
// bonus-owner.ts
import { queryFxcrm } from '../fxcrm/client.js'
export async function isBonusOwner(
userFsuid: string,
recordId: string
): Promise<boolean> {
const results = await queryFxcrm('bonus__c', [
{ field_name: '_id', field_values: [recordId], operator: 'EQ' }
])
if (results.length === 0) {
return false
}
return results[0].owner === userFsuid
}
```
---
## 整合到 tools.ts
### 修改後的 tools.ts
```typescript
// routes/tools.ts
import { checkPermission } from '../services/permission/index.js'
router.post('/:name', async (req: Request, res: Response) => {
const toolName = req.params.name
const user = req.user!
const body = req.body as ToolRequest
const args = body.arguments || {}
// 判斷操作類型
const action = getActionType(toolName)
// 如果是 FX-CRM 操作,進行細粒度權限檢查
if (toolName.startsWith('fxcrm_') || toolName.startsWith('odoo_')) {
const permissionResult = await checkPermission({
userFsuid: user.fsuid,
userPermission: user.permission as Permission,
objectApiName: args.object_api_name || args.model,
action,
recordId: args.record_id || args.id,
opportunityId: args.opportunity_id,
})
if (!permissionResult.allowed) {
return res.status(403).json({
success: false,
error: {
code: ErrorCode.PERMISSION_DENIED,
message: permissionResult.reason || '權限不足',
},
})
}
// 如果有記錄過濾條件,添加到查詢參數
if (permissionResult.recordFilter && args.domain) {
args.domain = ['&', ...permissionResult.recordFilter, ...args.domain]
} else if (permissionResult.recordFilter) {
args.domain = permissionResult.recordFilter
}
}
// 繼續執行工具...
})
function getActionType(toolName: string): ActionType {
if (toolName.includes('_search') || toolName.includes('_read') || toolName.includes('_get')) {
return 'read'
}
if (toolName.includes('_create')) {
return 'create'
}
if (toolName.includes('_update')) {
return 'update'
}
if (toolName.includes('_delete') || toolName.includes('_invalid')) {
return toolName.includes('_invalid') ? 'invalid' : 'delete'
}
return 'read'
}
```
---
## 實作步驟
### Phase 1: 基礎結構 ✅ 已完成
1. ✅ 建立 `services/permission/` 目錄結構
2. ✅ 實作 `types.ts` 型別定義
3. ✅ 實作 `object-category.ts` 物件分類
### Phase 2: 核心權限檢查 ✅ 已完成
1. ✅ 實作 `permission-checker.ts` 核心邏輯
2. ✅ 實作 `bonus-owner.ts` 獎金負責人檢查
3. ✅ 修改 `tools.ts` 整合權限檢查
### Phase 3: 商機成員檢查 ✅ 已完成
1. ✅ 新增 `services/fxcrm/client.ts` FX-CRM API 客戶端
2. ✅ 實作 `opportunity-member.ts` 商機成員檢查
3. ✅ 實作 `getUserOpportunityIds()` 取得用戶參與的商機
4. ✅ 新增 `services/fxcrm/tools.ts` FX-CRM 工具執行
### Phase 4: 測試與調整 ⏳ 待進行
1. 執行 TEST_PLAN.md 中的測試案例
2. 根據測試結果調整權限邏輯
3. 完善錯誤訊息和日誌
---
## 注意事項
1. **效能考量**:商機成員查詢需要快取,避免頻繁查詢 FX-CRM
2. **錯誤處理**:權限檢查失敗時要返回清楚的錯誤訊息
3. **日誌記錄**:所有權限檢查結果都要記錄到審計日誌
4. **向後相容**:保留現有的簡單權限檢查,新增細粒度檢查