feat(v0.7.1): P0 修复 + 企微 SSO + RBAC 细粒度 + audit_log

P0 修复:
- /api/ready import 错误 (_get_engine + settings.create_redis_client)
- 删 agent.otp_secret/otp_enabled 双字段 (migration 026)
- 重建 021_rbac migration (IF NOT EXISTS 兼容)

P1 新增:
- 企微 SSO (auth_wecom_sso.py, useWeChatWorkSSO composable, PortalSelect UA 检测)
- RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围 (rbac_service + seed_rbac + require_permission)
- audit_log 模型 + migration 027 + 服务 + API
- 管理后台 RBAC 权限矩阵 UI (PermissionsMatrix.vue)

质量:
- pytest 405 passed / 33 pre-existing failed / 4 xfailed (v0.7.1 引入失败 = 0)
- conftest GBK patch 强制 UTF-8 读 .env
- .gitignore 排除 *.b64 (含 admin token 凭据)
- DEPLOY-v0.7.1.md 7 步 runbook + 4 坑 + 回滚预案
This commit is contained in:
Simon
2026-06-22 17:38:47 +08:00
parent 2e6ac0f0ab
commit 78f60c6857
30 changed files with 2928 additions and 49 deletions
+7
View File
@@ -51,6 +51,13 @@ const routes = [
component: () => import('@/views/Roles.vue'),
meta: { title: '角色管理', requiresAuth: true },
},
{
// v0.7.1 task #91 — RBAC 细粒度权限矩阵可视化
path: 'permissions-matrix',
name: 'PermissionsMatrix',
component: () => import('@/views/PermissionsMatrix.vue'),
meta: { title: '权限矩阵', requiresAuth: true },
},
{
path: 'integrations',
name: 'Integrations',
@@ -0,0 +1,433 @@
<!--
=============================================================================
企微IT智能服务台 RBAC 权限矩阵可视化页 (v0.7.1 task #91)
=============================================================================
说明: GET /api/admin/roles/permissions/matrix,渲染 5 角色 × 4 资源 ×
4 操作 × 3 范围的完整矩阵表格给管理员一眼看到角色权限边界
特性:
- = 资源:操作 (16 ,4 资源 × 4 操作)
- = 角色 (5 ,user/agent/team_lead/auditor/admin)
- 单元格颜色:
· own 权限 浅蓝
· department 权限 蓝色
· all 权限 深蓝
· 无权限 灰色
- 顶部 scope 图例 + 角色筛选 + 资源筛选
- 支持导出 CSV(复制权限矩阵给合规审计)
-->
<template>
<div class="matrix-page">
<div class="page-header">
<div>
<div class="page-title">RBAC 权限矩阵</div>
<div class="page-desc">
5 角色 × 4 资源 × 4 操作 × 3 数据范围, 240 个权限点 (5 × 16 × 3)
</div>
</div>
<div class="header-actions">
<el-select
v-model="selectedRole"
placeholder="筛选角色"
size="default"
clearable
style="width: 180px"
>
<el-option
v-for="r in matrixData.roles"
:key="r.name"
:label="r.display_name + ' (' + r.name + ')'"
:value="r.name"
/>
</el-select>
<el-select
v-model="selectedResource"
placeholder="筛选资源"
size="default"
clearable
style="width: 180px"
>
<el-option
v-for="r in matrixData.resources"
:key="r"
:label="r"
:value="r"
/>
</el-select>
<el-button @click="exportCsv">
<el-icon><Download /></el-icon>
导出 CSV
</el-button>
<el-button @click="loadMatrix" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-skeleton v-if="loading && !hasData" :rows="10" animated />
<template v-else-if="hasData">
<!-- scope 图例 -->
<div class="legend">
<span class="legend-title">数据范围:</span>
<span class="legend-item legend-own">own 自己的</span>
<span class="legend-item legend-dept">department 部门的</span>
<span class="legend-item legend-all">all 全部</span>
<span class="legend-item legend-none">无权限</span>
</div>
<!-- 权限矩阵表 -->
<el-table
:data="filteredMatrixRows"
stripe
size="small"
:empty-text="loading ? '加载中...' : '无数据'"
class="matrix-table"
>
<el-table-column label="资源" prop="resource" width="140" fixed>
<template #default="{ row }">
<span class="resource-label">{{ row.resource }}</span>
</template>
</el-table-column>
<el-table-column label="操作" prop="action" width="100" fixed>
<template #default="{ row }">
<el-tag size="small" :type="getActionTagType(row.action)">
{{ row.action }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-for="role in filteredRoles"
:key="role.name"
:label="role.display_name"
min-width="140"
align="center"
>
<template #header>
<div class="role-header">
<div class="role-name">{{ role.display_name }}</div>
<div class="role-id">{{ role.name }}</div>
</div>
</template>
<template #default="{ row }">
<div class="perm-cell">
<el-tag
v-for="scope in getScopesForRole(row, role.name)"
:key="scope"
size="small"
:class="['scope-tag', `scope-${scope}`]"
>
{{ scope }}
</el-tag>
<span v-if="getScopesForRole(row, role.name).length === 0" class="no-perm"></span>
</div>
</template>
</el-table-column>
</el-table>
<!-- 角色详情 -->
<div class="role-detail">
<div class="section-title">角色详情</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="role in filteredRoles"
:key="role.name"
:label="role.display_name + ' (' + role.name + ')'"
>
<div class="role-meta">
<div class="role-desc">{{ role.description || '—' }}</div>
<div class="role-perm-count">
权限数: <b>{{ role.permission_count }}</b>
<el-tag v-if="role.is_default" type="info" size="small" style="margin-left: 8px">默认</el-tag>
</div>
</div>
</el-descriptions-item>
</el-descriptions>
</div>
</template>
<el-empty v-else description="加载失败, 请检查网络或权限" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, Refresh } from '@element-plus/icons-vue'
import apiClient from '@/api/index'
// ---- 类型定义 ----
interface Role {
name: string
display_name: string
description: string
is_default: boolean
permission_count: number
}
interface MatrixData {
roles: Role[]
resources: string[]
actions: string[]
scopes: string[]
matrix: Record<string, Record<string, boolean>>
}
interface MatrixRow {
resource: string
action: string
key: string // "resource:action"
}
// ---- 状态 ----
const loading = ref(false)
const matrixData = ref<MatrixData>({
roles: [],
resources: [],
actions: [],
scopes: [],
matrix: {},
})
const selectedRole = ref<string>('')
const selectedResource = ref<string>('')
const hasData = computed(() => matrixData.value.roles.length > 0)
const filteredRoles = computed(() => {
if (!selectedRole.value) return matrixData.value.roles
return matrixData.value.roles.filter((r) => r.name === selectedRole.value)
})
// 矩阵行: 资源 × 操作 笛卡尔积
const matrixRows = computed<MatrixRow[]>(() => {
const rows: MatrixRow[] = []
for (const r of matrixData.value.resources) {
for (const a of matrixData.value.actions) {
rows.push({ resource: r, action: a, key: `${r}:${a}` })
}
}
return rows
})
const filteredMatrixRows = computed(() => {
if (!selectedResource.value) return matrixRows.value
return matrixRows.value.filter((row) => row.resource === selectedResource.value)
})
// ---- 方法 ----
async function loadMatrix() {
loading.value = true
try {
const resp = await apiClient.get('/admin/roles/permissions/matrix')
if (resp.data?.code === 0 && resp.data.data) {
matrixData.value = resp.data.data
} else {
ElMessage.error('拉取权限矩阵失败')
}
} catch (e: any) {
console.error('loadMatrix 失败:', e)
ElMessage.error('加载失败: ' + (e?.message || '未知错误'))
} finally {
loading.value = false
}
}
/**
* 拿到角色在 (resource, action) 下的所有 scope (own/department/all)。
* 例: agent 看 conversation:read 时,可能有 own + all 两个 scope。
*/
function getScopesForRole(row: MatrixRow, roleName: string): string[] {
const result: string[] = []
for (const scope of matrixData.value.scopes) {
const key = `${row.resource}:${row.action}:${scope}`
if (matrixData.value.matrix[roleName]?.[key]) {
result.push(scope)
}
}
return result
}
function getActionTagType(action: string): string {
const types: Record<string, string> = {
read: 'info',
create: 'success',
update: 'warning',
delete: 'danger',
}
return types[action] || 'info'
}
function exportCsv() {
// CSV 表头: 资源,操作, <每个角色>
const header = ['resource', 'action', ...filteredRoles.value.map((r) => r.name)]
const lines = [header.join(',')]
for (const row of filteredMatrixRows.value) {
const cells = [row.resource, row.action]
for (const role of filteredRoles.value) {
const scopes = getScopesForRole(row, role.name)
cells.push(scopes.join('|') || '')
}
lines.push(cells.join(','))
}
const csv = lines.join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `rbac-matrix-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('CSV 已导出')
}
onMounted(() => {
loadMatrix()
})
</script>
<style scoped>
.matrix-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: #f1f5f9;
}
.page-desc {
font-size: 13px;
color: #94a3b8;
margin-top: 4px;
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.legend {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
padding: 12px 16px;
background: rgba(30, 41, 59, 0.5);
border-radius: 8px;
font-size: 13px;
}
.legend-title {
color: #94a3b8;
font-weight: 500;
margin-right: 4px;
}
.legend-item {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.legend-own {
background: #dbeafe;
color: #1e40af;
}
.legend-dept {
background: #93c5fd;
color: #1e3a8a;
}
.legend-all {
background: #1e40af;
color: #fff;
}
.legend-none {
background: #e2e8f0;
color: #64748b;
}
.matrix-table {
margin-bottom: 24px;
}
.resource-label {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #1e293b;
}
.role-header {
text-align: center;
}
.role-name {
font-weight: 600;
color: #1e293b;
}
.role-id {
font-size: 11px;
color: #94a3b8;
font-family: monospace;
margin-top: 2px;
}
.perm-cell {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
}
.scope-tag {
font-size: 10px;
font-family: monospace;
}
.scope-own {
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}
.scope-department {
background: #93c5fd;
color: #1e3a8a;
border-color: #60a5fa;
}
.scope-all {
background: #1e40af;
color: #fff;
border-color: #1e3a8a;
}
.no-perm {
color: #cbd5e1;
font-size: 14px;
}
.role-detail {
margin-top: 24px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
}
.role-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.role-desc {
font-size: 13px;
color: #475569;
line-height: 1.4;
}
.role-perm-count {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
}
</style>