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:
weichen
2026-09-13 02:50:23 +00:00
committed by weichen
co-authored by Cursor
parent b402426257
commit ec2ea02e8a
5 changed files with 231 additions and 5 deletions
+102
View File
@@ -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")
}
}
+20
View File
@@ -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)
+41
View File
@@ -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
}