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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 09:48:19 +00:00
co-authored by Cursor
parent 6d04d98c5f
commit a82bfdb6d1
2 changed files with 17 additions and 34 deletions
+3 -26
View File
@@ -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 {
+14 -8
View File
@@ -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")
}
}