From a82bfdb6d13f510041acc755b3a31c1701a4fd0b Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:48:19 +0000 Subject: [PATCH] fix(authz): allow project members stage chat without document duties Live workflows still restrict document actions to duty holders, but AllowedStageKeys now include active/completed stages so members can chat. Co-authored-by: Cursor --- internal/projectinstance/stage_access.go | 29 ++----------------- internal/projectinstance/stage_access_test.go | 22 +++++++++----- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index a2552526..253322d7 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -62,25 +62,20 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI continue } status := stageStatus[stPlan.StageKey] - hasDuty := stageDutyForUser(stPlan, userID) - hasUnmanaged := stageHasUnmanagedDoc(stPlan) // View exemption: every enabled doc is listable. Action keys stay duty/creator-none. for _, dt := range stPlan.DocTasks { if dt.Enabled { out.ViewableTaskKeys[dt.TaskKey] = true } } - allowStage := false + // Stage chat/history: any caller who already passed project membership may + // see active/completed stages. Document actions remain duty-scoped below. switch status { case StageActive, StageCompleted: - allowStage = hasDuty || (isCreator && hasUnmanaged) + out.AllowedStageKeys[stPlan.StageKey] = true default: - allowStage = false - } - if !allowStage { continue } - out.AllowedStageKeys[stPlan.StageKey] = true for _, dt := range stPlan.DocTasks { if !dt.Enabled { continue @@ -93,15 +88,6 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI return out, nil } -func stageDutyForUser(st StagePlan, userID int64) bool { - for _, dt := range st.DocTasks { - if dt.Enabled && docTaskDutyForUser(dt, userID) { - return true - } - } - return false -} - func docTaskDutyMode(dt DocTaskPlan) DutyMode { mode, _, _ := ResolveDocTaskDuty(dt) return mode @@ -117,15 +103,6 @@ func docTaskDutyForUser(dt DocTaskPlan, userID int64) bool { effective.ApproverUserID == userID } -func stageHasUnmanagedDoc(st StagePlan) bool { - for _, dt := range st.DocTasks { - if dt.Enabled && docTaskDutyMode(dt) == DutyModeNone { - return true - } - } - return false -} - // TaskKeyAllowed 判断 task_key 是否可发起非 view 动作。 func (a *StageAccess) TaskKeyAllowed(taskKey string) bool { if a == nil || !a.Restricted { diff --git a/internal/projectinstance/stage_access_test.go b/internal/projectinstance/stage_access_test.go index d246415c..89c3d8f9 100644 --- a/internal/projectinstance/stage_access_test.go +++ b/internal/projectinstance/stage_access_test.go @@ -38,9 +38,12 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) { t.Fatal("creator should access active stage") } - outsiderAccess, _ := svc.ResolveStageAccess(ctx, 42, 9, false) - if outsiderAccess.StageKeyAllowed("V0.1_triage") { - t.Fatal("user without duty should not access active stage chat") + memberAccess, _ := svc.ResolveStageAccess(ctx, 42, 9, false) + if !memberAccess.StageKeyAllowed("V0.1_triage") { + t.Fatal("project member without duty should access active stage chat") + } + if memberAccess.TaskKeyAllowed("intake") { + t.Fatal("member without duty must not act on governed intake task") } } @@ -110,22 +113,25 @@ func TestResolveStageAccess_NoLiveInstanceUnrestricted(t *testing.T) { } } -func TestResolveStageAccess_LiveOutsiderStillDenied(t *testing.T) { +func TestResolveStageAccess_LiveMemberChatWithoutDuty(t *testing.T) { ctx := context.Background() store := newMemStore() _ = SeedProjectMeta(store, 70, LifecyclePlanning, "", 1, 0) svc := NewService(store, testResolver(), nil, nil) mustActivateTestPlan(t, svc, 70, 1) - outsider, err := svc.ResolveStageAccess(ctx, 70, 9, false) + member, err := svc.ResolveStageAccess(ctx, 70, 9, false) if err != nil { t.Fatal(err) } - if !outsider.Restricted { + if !member.Restricted { t.Fatal("live instance must restrict") } - if outsider.TaskKeyAllowed("intake") || outsider.StageKeyAllowed("V0.1_triage") { - t.Fatal("outsider must not access live active stage") + if !member.StageKeyAllowed("V0.1_triage") { + t.Fatal("member without duty should access active stage chat") + } + if member.TaskKeyAllowed("intake") { + t.Fatal("member without duty must not launch governed intake") } }