feat(abac): restrict workflow document access by active stage duties.
Resolve stage access from live instance assignments and filter document list/content for non-assigned roles. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
b402426257
commit
ec2ea02e8a
@@ -91,8 +91,17 @@ ok chat-agent-system/internal/server 0.792s (TestProjectWorkflow*)
|
||||
- `POST /workflow-instances/{id}/stages/{stage}/advance`
|
||||
- `TestService_AdvanceStageGate` / `TestService_AdvanceStageBlockedWithoutApproval`
|
||||
- 前端 `StageAdvanceBanner` 创建者确认推进卡片
|
||||
- **后续 (Phase 3/4 交界)**:
|
||||
1. 阶段级 ABAC 与聊天/文档权限联动
|
||||
2. PG 环境全链路 E2E + 真实 Agent 验收(按测试报告规范)
|
||||
3. `ProjectSettingsModal` 配置步骤 UI 增强(阶段树、影响提示)
|
||||
4. 文档批准后自动刷新 readiness / Outbox 通知(Phase 4)
|
||||
- **2026-09-12 续(顺序推荐执行)**:
|
||||
1. ✅ **PG 迁移 + 集成实测**(`00017` 已升至 v17;`TestPG_ActivationAndStageAdvance` PASS)
|
||||
2. ✅ **阶段级 ABAC MVP**(文档 list/content 按 active 阶段 + 三权过滤;`TestResolveStageAccess_ActiveStageDutyOnly` PASS)
|
||||
3. ⏳ **Phase 4 通知**(文档批准后自动刷新 readiness、Outbox、聊天/SSE 全面接 ABAC)
|
||||
|
||||
### 顺序影响说明
|
||||
|
||||
| 顺序 | 选项 | 依赖 | 若提前做的风险 |
|
||||
|------|------|------|----------------|
|
||||
| 1 | PG 迁移 + 集成实测 | 无 | — |
|
||||
| 2 | 阶段 ABAC | live instance + 00017 表 | 在 memory stub 上写 ABAC 无法验收;接口行为可能与 PG 不一致 |
|
||||
| 3 | Outbox/自动刷新 | 稳定 stage gate + ABAC | 通知推送后仍可能越权读文档,验收失真 |
|
||||
|
||||
**推荐路径**:先固化数据面(迁移实测)→ 再补安全门禁(ABAC)→ 最后做体验增强(通知)。
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// StageAccess 用户在某项目实例下可访问的阶段与文档任务范围。
|
||||
type StageAccess struct {
|
||||
Restricted bool `json:"restricted"`
|
||||
ActiveStageKey string `json:"active_stage_key,omitempty"`
|
||||
AllowedStageKeys map[string]bool `json:"allowed_stage_keys,omitempty"`
|
||||
AllowedTaskKeys map[string]bool `json:"allowed_task_keys,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveStageAccess 基于 live 实例与三权指派计算阶段 ABAC 范围。
|
||||
// 无 live 实例时不限制(legacy 项目兼容)。
|
||||
func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userID int64, isCreator bool) (*StageAccess, error) {
|
||||
out := &StageAccess{
|
||||
AllowedStageKeys: make(map[string]bool),
|
||||
AllowedTaskKeys: make(map[string]bool),
|
||||
}
|
||||
if s == nil || s.store == nil || projectID <= 0 || userID <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil || live.Status == InstanceCompleted {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
out.Restricted = true
|
||||
out.ActiveStageKey = detail.ActiveStageKey
|
||||
|
||||
stageStatus := map[string]string{}
|
||||
for _, st := range detail.Stages {
|
||||
stageStatus[st.StageKey] = st.Status
|
||||
}
|
||||
|
||||
for _, stPlan := range live.ConfigSnapshot.Stages {
|
||||
if !stPlan.Enabled {
|
||||
continue
|
||||
}
|
||||
status := stageStatus[stPlan.StageKey]
|
||||
hasDuty := stageDutyForUser(stPlan, userID)
|
||||
allow := false
|
||||
switch status {
|
||||
case StageActive:
|
||||
allow = isCreator || hasDuty
|
||||
case StageCompleted:
|
||||
allow = hasDuty || isCreator
|
||||
default:
|
||||
allow = false
|
||||
}
|
||||
if !allow {
|
||||
continue
|
||||
}
|
||||
out.AllowedStageKeys[stPlan.StageKey] = true
|
||||
for _, dt := range stPlan.DocTasks {
|
||||
if dt.Enabled {
|
||||
out.AllowedTaskKeys[dt.TaskKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func stageDutyForUser(st StagePlan, userID int64) bool {
|
||||
for _, dt := range st.DocTasks {
|
||||
if !dt.Enabled {
|
||||
continue
|
||||
}
|
||||
if dt.Duties.AuthorUserID == userID || dt.Duties.ReviewerUserID == userID || dt.Duties.ApproverUserID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TaskKeyAllowed 判断 task_key 是否在允许范围内。
|
||||
func (a *StageAccess) TaskKeyAllowed(taskKey string) bool {
|
||||
if a == nil || !a.Restricted {
|
||||
return true
|
||||
}
|
||||
return a.AllowedTaskKeys[taskKey]
|
||||
}
|
||||
|
||||
// TaskKeyToStage 从实例快照解析 task 所属阶段。
|
||||
func TaskKeyToStage(plan WorkflowPlan, taskKey string) string {
|
||||
for _, st := range plan.Stages {
|
||||
for _, dt := range st.DocTasks {
|
||||
if dt.TaskKey == taskKey {
|
||||
return st.StageKey
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
|
||||
_, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("save plan: %v", err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, 42, 1, true)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
_, err = svc.Activate(ctx, 42, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
|
||||
bdUser := int64(99)
|
||||
access, err := svc.ResolveStageAccess(ctx, 42, bdUser, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !access.Restricted {
|
||||
t.Fatal("expected restricted access under live instance")
|
||||
}
|
||||
if access.TaskKeyAllowed("intake") {
|
||||
t.Fatal("user without duty should not access active stage docs")
|
||||
}
|
||||
|
||||
authorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, false)
|
||||
if !authorAccess.TaskKeyAllowed("intake") {
|
||||
t.Fatal("author on active stage should access intake")
|
||||
}
|
||||
|
||||
creatorAccess, _ := svc.ResolveStageAccess(ctx, 42, 1, true)
|
||||
if !creatorAccess.TaskKeyAllowed("intake") {
|
||||
t.Fatal("creator should access active stage")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := s.requireProjectAccess(w, r, projectID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if s.svc == nil || s.svc.DocGov() == nil {
|
||||
http.Error(w, "docgov engine not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -37,6 +42,7 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
|
||||
http.Error(w, "list project documents failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs)
|
||||
writeJSONResponse(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
@@ -53,6 +59,20 @@ func (s *Server) handleDocGovGetDocumentContent(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
docRec, err := s.svc.DocGov().GetDocument(r.Context(), docID)
|
||||
if err != nil || docRec == nil {
|
||||
http.Error(w, "document not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
user, ok := s.requireProjectAccess(w, r, docRec.ProjectID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !s.allowWorkflowDocTaskAccess(r.Context(), docRec.ProjectID, user.ID, docRec.TaskKey) {
|
||||
http.Error(w, `{"error":"WORKFLOW_ACCESS_DENIED","message":"无权访问该阶段文档"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := s.svc.DocGov().ReadDocumentContent(r.Context(), docID, s.repoRoot())
|
||||
if err != nil {
|
||||
http.Error(w, "read document content failed: "+err.Error(), http.StatusNotFound)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
)
|
||||
|
||||
func (s *Server) allowWorkflowDocTaskAccess(ctx context.Context, projectID, userID int64, taskKey string) bool {
|
||||
if taskKey == "" || s.instSvc == nil || !s.instSvc.Available() {
|
||||
return true
|
||||
}
|
||||
isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID)
|
||||
access, err := s.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
return true
|
||||
}
|
||||
return access.TaskKeyAllowed(taskKey)
|
||||
}
|
||||
|
||||
func (s *Server) filterWorkflowDocsByStageAccess(
|
||||
ctx context.Context,
|
||||
projectID, userID int64,
|
||||
docs []docgov.WorkflowDocumentSummary,
|
||||
) []docgov.WorkflowDocumentSummary {
|
||||
if len(docs) == 0 || s.instSvc == nil || !s.instSvc.Available() {
|
||||
return docs
|
||||
}
|
||||
isCreator, _ := s.pwSvc.IsProjectCreator(ctx, projectID, userID)
|
||||
access, err := s.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
return docs
|
||||
}
|
||||
out := make([]docgov.WorkflowDocumentSummary, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
if access.TaskKeyAllowed(d.TaskKey) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user