feat: add project-level policy management (add/remove per project)

- Add "授权" button on each linked project row
- New dialog to select/deselect policies per project
- Backend does incremental diff: only attach new, detach removed
- Handle PolicyAttachConflict gracefully

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-20 19:28:14 +08:00
parent 6dd3ac5c0d
commit c58fe56d89
3 changed files with 115 additions and 6 deletions

View File

@ -26,6 +26,7 @@ urlpatterns = [
path('iam-users/<int:pk>/projects/', views.iam_user_project_list_view),
path('iam-users/<int:pk>/projects/add/', views.iam_user_project_add_view),
path('iam-users/<int:pk>/projects/<int:pid>/', views.iam_user_project_update_view),
path('iam-users/<int:pk>/projects/<int:pid>/policies/', views.iam_user_project_policies_view),
path('iam-users/<int:pk>/projects/<int:pid>/delete/', views.iam_user_project_delete_view),
path('iam-users/<int:pk>/projects/toggle-all/', views.iam_user_project_toggle_all_view),

View File

@ -610,11 +610,7 @@ def iam_user_project_add_view(request, pk):
d['project_name'])
attached.append(policy_name)
except VolcengineAPIError as e:
if 'PolicyAttachConflict' in str(e):
# 全局已有此策略,项目级无需重复附加,视为成功
attached.append(policy_name)
else:
auth_errors.append(f"{policy_name}: {e}")
auth_errors.append(f"{policy_name}: {e}")
obj.attached_policies = attached
obj.save(update_fields=['attached_policies'])
@ -652,6 +648,66 @@ def iam_user_project_update_view(request, pk, pid):
return Response(IAMUserProjectSerializer(project).data)
@api_view(['PUT'])
def iam_user_project_policies_view(request, pk, pid):
"""更新项目级授权策略(增量对比:移除旧的、添加新的)"""
try:
project = IAMUserProject.objects.get(pk=pid, iam_user_id=pk)
user = project.iam_user
except IAMUserProject.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
new_policies = request.data.get('policies', [])
old_policies = project.attached_policies or []
account, ak, sk = _get_volc_account(user.volc_account_id)
if not ak:
return Response({'error': 'no_credentials'}, status=status.HTTP_400_BAD_REQUEST)
svc = IAMService(ak, sk)
attached = []
detached = []
errors = []
# Remove policies that were removed
to_remove = [p for p in old_policies if p not in new_policies]
for policy_name in to_remove:
try:
svc.detach_policy_in_project(user.username, policy_name, project.project_name)
detached.append(policy_name)
except VolcengineAPIError as e:
errors.append(f"移除 {policy_name}: {e}")
# Add policies that are new
to_add = [p for p in new_policies if p not in old_policies]
for policy_name in to_add:
try:
svc.attach_policy_in_project(user.username, policy_name, project.project_name)
attached.append(policy_name)
except VolcengineAPIError as e:
if 'PolicyAttachConflict' in str(e):
attached.append(policy_name)
else:
errors.append(f"添加 {policy_name}: {e}")
project.attached_policies = new_policies
project.save(update_fields=['attached_policies'])
AlertRecord.objects.create(
iam_user=user,
alert_type=AlertRecord.AlertType.MANUAL,
title=f"更新项目 {project.project_name} 授权策略",
content=f"操作人: {request.user.username},添加: {attached},移除: {detached}"
+ (f",失败: {errors}" if errors else ""),
)
result = {'message': f'已更新,添加 {len(attached)} 个、移除 {len(detached)} 个策略',
'project': IAMUserProjectSerializer(project).data}
if errors:
result['warnings'] = errors
return Response(result)
@api_view(['DELETE'])
def iam_user_project_delete_view(request, pk, pid):
"""移除关联项目:回收权限 + 移出监测"""

View File

@ -204,14 +204,37 @@
<el-switch :model-value="row.monitor_enabled" @change="val => handleToggleProject(row, val)" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<el-table-column label="操作" width="140" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" text @click="openProjectPolicies(row)">授权</el-button>
<el-button size="small" type="danger" text @click="handleRemoveProject(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<!-- Project Policies Dialog -->
<el-dialog v-model="projectPolicyVisible"
:title="`${projectPolicyProject?.project_name} 项目授权`"
width="90%" style="max-width: 550px;">
<p style="margin-bottom:12px; color:#606266; font-size:13px;">
子账号 <strong>{{ projectsUser?.username }}</strong> 在此项目下的权限
</p>
<el-checkbox-group v-model="projectPolicySelected">
<div style="display:flex; flex-direction:column; gap:8px;">
<el-checkbox label="ArkFullAccess">方舟/Seedance 完整权限</el-checkbox>
<el-checkbox label="ArkReadOnlyAccess">方舟只读</el-checkbox>
<el-checkbox label="TOSFullAccess">对象存储完整权限</el-checkbox>
<el-checkbox label="TOSReadOnlyAccess">对象存储只读</el-checkbox>
<el-checkbox label="AccessKeySelfManageAccess">自管理密钥</el-checkbox>
</div>
</el-checkbox-group>
<template #footer>
<el-button @click="projectPolicyVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveProjectPolicies" :loading="projectPolicySaving">保存</el-button>
</template>
</el-dialog>
<!-- Quota History Dialog -->
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="90%" style="max-width: 800px;">
<el-table :data="quotaHistory" stripe v-loading="historyLoading" empty-text="暂无划拨记录">
@ -538,6 +561,35 @@ async function handleToggleProject(row, val) {
}
}
// === Project Policies ===
const projectPolicyVisible = ref(false)
const projectPolicyProject = ref(null)
const projectPolicySelected = ref([])
const projectPolicySaving = ref(false)
function openProjectPolicies(row) {
projectPolicyProject.value = row
projectPolicySelected.value = [...(row.attached_policies || [])]
projectPolicyVisible.value = true
}
async function handleSaveProjectPolicies() {
projectPolicySaving.value = true
try {
const { data } = await api.put(
`/api/v1/iam-users/${projectsUser.value.id}/projects/${projectPolicyProject.value.id}/policies/`,
{ policies: projectPolicySelected.value }
)
ElMessage.success(data.message || '已更新')
projectPolicyVisible.value = false
await loadUserProjects(projectsUser.value.id)
} catch (e) {
ElMessage.error(e.response?.data?.message || '更新失败')
} finally {
projectPolicySaving.value = false
}
}
async function handleRemoveProject(row) {
await ElMessageBox.confirm(`确定移除项目 "${row.project_name}" 吗?`, '确认', { type: 'warning' })
try {