From ec2ea02e8aba54282c040fd1d3a3f412d12dcf20 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 12 Sep 2026 03:10:47 +0000 Subject: [PATCH] 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 --- ...project-workflow-governance-test-report.md | 19 +++- internal/projectinstance/stage_access.go | 102 ++++++++++++++++++ internal/projectinstance/stage_access_test.go | 54 ++++++++++ internal/server/project_doc_handlers.go | 20 ++++ internal/server/stage_access_helpers.go | 41 +++++++ 5 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 internal/projectinstance/stage_access.go create mode 100644 internal/projectinstance/stage_access_test.go create mode 100644 internal/server/stage_access_helpers.go diff --git a/.doc/test-reports/2026-09-12-phase3-project-workflow-governance-test-report.md b/.doc/test-reports/2026-09-12-phase3-project-workflow-governance-test-report.md index 928879ff..c7e16047 100644 --- a/.doc/test-reports/2026-09-12-phase3-project-workflow-governance-test-report.md +++ b/.doc/test-reports/2026-09-12-phase3-project-workflow-governance-test-report.md @@ -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)→ 最后做体验增强(通知)。 diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go new file mode 100644 index 00000000..e21be959 --- /dev/null +++ b/internal/projectinstance/stage_access.go @@ -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 "" +} diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go new file mode 100644 index 00000000..fe5b4d34 --- /dev/null +++ b/internal/projectinstance/stage_access_test.go @@ -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") + } +} diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index 0e5c630d..c1bcc808 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -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) diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go new file mode 100644 index 00000000..75a730e8 --- /dev/null +++ b/internal/server/stage_access_helpers.go @@ -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 +}