fix(projectinstance): distinguish never-activated vs closed governing instances
GetLiveInstance remains active-only; access ABAC now uses GetGoverningInstanceForAccess so completed/terminated PG rows are not treated as unrestricted legacy projects, while brand-new projects without instances stay open for creators. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
package projectinstance_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
func TestPG_GoverningAccess_NeverActiveUnrestricted(t *testing.T) {
|
||||
pool := openPGPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
store := projectinstance.NewStore(pool)
|
||||
svc := projectinstance.NewService(store, workflowdef.NewStore(pool), projectworkflow.NewStore(pool), nil)
|
||||
|
||||
var creatorID int64
|
||||
err := pool.QueryRow(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 1`).Scan(&creatorID)
|
||||
if err != nil || creatorID <= 0 {
|
||||
t.Skip("need at least one user row")
|
||||
}
|
||||
|
||||
var projectID int64
|
||||
code := "pg-access-none-" + time.Now().Format("150405.000")
|
||||
err = pool.QueryRow(ctx, `
|
||||
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
|
||||
VALUES ($1, 'Access Never Active', 'active', $2, 'planning', '', 1, 0) RETURNING id
|
||||
`, code, creatorID).Scan(&projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM project_workflow_instances WHERE project_id = $1`, projectID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
|
||||
})
|
||||
|
||||
access, err := svc.ResolveStageAccess(ctx, projectID, creatorID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if access.Restricted {
|
||||
t.Fatal("never-activated project must be unrestricted")
|
||||
}
|
||||
d := svc.CheckDocTaskAccess(ctx, projectID, creatorID, "intake", projectinstance.DocAccessActionRun, true)
|
||||
if !d.Allowed {
|
||||
t.Fatalf("run on never-activated must allow, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPG_GoverningAccess_ActiveCreatorDutyOutsider(t *testing.T) {
|
||||
pool := openPGPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
var creatorID, reviewerID, approverID, outsiderID int64
|
||||
err := pool.QueryRow(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 1`).Scan(&creatorID)
|
||||
if err != nil {
|
||||
t.Skip("need users")
|
||||
}
|
||||
_ = pool.QueryRow(ctx, `SELECT id FROM users WHERE id <> $1 ORDER BY id ASC LIMIT 1`, creatorID).Scan(&reviewerID)
|
||||
_ = pool.QueryRow(ctx, `SELECT id FROM users WHERE id <> $1 AND id <> $2 ORDER BY id ASC LIMIT 1`, creatorID, reviewerID).Scan(&approverID)
|
||||
_ = pool.QueryRow(ctx, `SELECT id FROM users WHERE id <> ALL($1::bigint[]) ORDER BY id ASC LIMIT 1`, []int64{creatorID, reviewerID, approverID}).Scan(&outsiderID)
|
||||
if reviewerID <= 0 || approverID <= 0 {
|
||||
t.Skip("need ≥3 users for separation of duty")
|
||||
}
|
||||
if outsiderID <= 0 {
|
||||
outsiderID = creatorID + 9_000_000
|
||||
}
|
||||
|
||||
wfStore := workflowdef.NewStore(pool)
|
||||
def, err := workflowdef.LoadManifestDefinition("main.v2.json")
|
||||
if err != nil {
|
||||
t.Fatalf("load manifest: %v", err)
|
||||
}
|
||||
if err := wfStore.UpsertPublishedSeed(ctx, workflowdef.CreateDefinitionInput{
|
||||
Key: workflowdef.OfficialKeyMain,
|
||||
Name: "商务对接流程",
|
||||
Definition: def,
|
||||
}, creatorID); err != nil {
|
||||
t.Fatalf("seed official: %v", err)
|
||||
}
|
||||
|
||||
instStore := projectinstance.NewStore(pool)
|
||||
svc := projectinstance.NewService(instStore, wfStore, projectworkflow.NewStore(pool), nil)
|
||||
|
||||
var projectID int64
|
||||
code := "pg-access-live-" + time.Now().Format("150405.000")
|
||||
err = pool.QueryRow(ctx, `
|
||||
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
|
||||
VALUES ($1, 'Access Live', 'active', $2, 'planning', '', 1, 0) RETURNING id
|
||||
`, code, creatorID).Scan(&projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM project_workflow_instances WHERE project_id = $1`, projectID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
|
||||
})
|
||||
|
||||
plan, err := svc.BuildDefaultPlan(ctx, workflowdef.OfficialKeyMain, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("default plan: %v", err)
|
||||
}
|
||||
for i := range plan.Stages {
|
||||
for j := range plan.Stages[i].DocTasks {
|
||||
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
|
||||
AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = svc.SaveWorkflowPlan(ctx, projectID, creatorID, true, projectinstance.SavePlanInput{
|
||||
Plan: *plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
})
|
||||
if err != nil {
|
||||
var ve *projectinstance.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
t.Fatalf("save validation: %+v", ve.Issues)
|
||||
}
|
||||
t.Fatalf("save: %v plan_stages=%d", err, len(plan.Stages))
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, projectID, creatorID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
if _, err := svc.Activate(ctx, projectID, creatorID, true, projectinstance.ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
}); err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
|
||||
creatorAccess, err := svc.ResolveStageAccess(ctx, projectID, creatorID, true)
|
||||
if err != nil || !creatorAccess.Restricted || !creatorAccess.TaskKeyAllowed("intake") {
|
||||
t.Fatalf("creator under live must allow intake, got %+v err=%v", creatorAccess, err)
|
||||
}
|
||||
dutyRun := svc.CheckDocTaskAccess(ctx, projectID, creatorID, "intake", projectinstance.DocAccessActionRun, false)
|
||||
if !dutyRun.Allowed {
|
||||
t.Fatalf("author duty must run, got %+v", dutyRun)
|
||||
}
|
||||
outsider := svc.CheckDocTaskAccess(ctx, projectID, outsiderID, "intake", projectinstance.DocAccessActionRun, false)
|
||||
if outsider.Allowed || outsider.Code != projectinstance.AccessCodeForbiddenDuty {
|
||||
t.Fatalf("outsider must DOC_DUTY_FORBIDDEN, got %+v", outsider)
|
||||
}
|
||||
outsiderStage, _ := svc.ResolveStageAccess(ctx, projectID, outsiderID, false)
|
||||
if !outsiderStage.Restricted || outsiderStage.TaskKeyAllowed("intake") {
|
||||
t.Fatalf("outsider stage must deny, got %+v", outsiderStage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPG_GoverningAccess_CompletedAndTerminatedDeny(t *testing.T) {
|
||||
pool := openPGPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
store := projectinstance.NewStore(pool)
|
||||
svc := projectinstance.NewService(store, workflowdef.NewStore(pool), projectworkflow.NewStore(pool), nil)
|
||||
|
||||
for _, status := range []string{projectinstance.InstanceCompleted, projectinstance.InstanceTerminated} {
|
||||
status := status
|
||||
t.Run(status, func(t *testing.T) {
|
||||
var creatorID int64
|
||||
if err := pool.QueryRow(ctx, `SELECT id FROM users ORDER BY id ASC LIMIT 1`).Scan(&creatorID); err != nil {
|
||||
t.Skip("need users")
|
||||
}
|
||||
var projectID int64
|
||||
code := "pg-access-" + status + "-" + time.Now().Format("150405.000000")
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projects (code, name, status, creator_user_id, lifecycle_status, active_workflow_key, workflow_selection_version, workflow_plan_revision)
|
||||
VALUES ($1, $2, 'active', $3, 'planning', '', 1, 0) RETURNING id
|
||||
`, code, "Access "+status, creatorID).Scan(&projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert project: %v", err)
|
||||
}
|
||||
var instID int64
|
||||
err = pool.QueryRow(ctx, `
|
||||
INSERT INTO project_workflow_instances (
|
||||
project_id, workflow_version_id, instance_no, status, config_revision, plan_hash,
|
||||
config_snapshot, created_by, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, 1, 1, $2, 1, 'hash', '{"workflow_definition_key":"x","workflow_version":1,"stages":[]}'::jsonb,
|
||||
$3, now(), now()
|
||||
) RETURNING id
|
||||
`, projectID, status, creatorID).Scan(&instID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert instance: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM project_workflow_instances WHERE project_id = $1`, projectID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM projects WHERE id = $1`, projectID)
|
||||
})
|
||||
|
||||
hasLive, err := store.HasLiveInstance(ctx, projectID)
|
||||
if err != nil || hasLive {
|
||||
t.Fatalf("HasLiveInstance must be false for %s, has=%v err=%v", status, hasLive, err)
|
||||
}
|
||||
live, err := store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live != nil {
|
||||
t.Fatalf("GetLiveInstance must be nil for %s, got %+v err=%v", status, live, err)
|
||||
}
|
||||
gov, err := store.GetGoverningInstanceForAccess(ctx, projectID)
|
||||
if err != nil || gov == nil || gov.Status != status {
|
||||
t.Fatalf("governing must be %s, got %+v err=%v", status, gov, err)
|
||||
}
|
||||
|
||||
access, err := svc.ResolveStageAccess(ctx, projectID, creatorID, true)
|
||||
if err != nil || !access.Restricted || access.TaskKeyAllowed("intake") {
|
||||
t.Fatalf("ResolveStageAccess must restrict closed, got %+v err=%v", access, err)
|
||||
}
|
||||
for _, action := range []string{projectinstance.DocAccessActionRun, projectinstance.DocAccessActionEdit} {
|
||||
d := svc.CheckDocTaskAccess(ctx, projectID, creatorID, "intake", action, true)
|
||||
if d.Allowed || d.Code != projectinstance.AccessCodeInstanceClosed {
|
||||
t.Fatalf("%s %s must WORKFLOW_INSTANCE_CLOSED, got %+v", status, action, d)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPG_GoverningAccess_QueryErrorFailClosed(t *testing.T) {
|
||||
svc := projectinstance.NewService(&errPoolStore{err: errors.New("db down")}, nil, nil, nil)
|
||||
_, err := svc.ResolveStageAccess(context.Background(), 1, 1, true)
|
||||
if err == nil {
|
||||
t.Fatal("ResolveStageAccess must propagate store errors")
|
||||
}
|
||||
d := svc.CheckDocTaskAccess(context.Background(), 1, 1, "intake", projectinstance.DocAccessActionRun, true)
|
||||
if d.Allowed || d.Code != projectinstance.AccessCodeInstanceClosed {
|
||||
t.Fatalf("CheckDocTaskAccess must fail-closed on store error, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
type errPoolStore struct {
|
||||
projectinstance.Store
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errPoolStore) GetGoverningInstanceForAccess(context.Context, int64) (*projectinstance.WorkflowInstance, error) {
|
||||
return nil, e.err
|
||||
}
|
||||
|
||||
func (e *errPoolStore) GetLiveInstance(context.Context, int64) (*projectinstance.WorkflowInstance, error) {
|
||||
return nil, e.err
|
||||
}
|
||||
|
||||
func (e *errPoolStore) HasLiveInstance(context.Context, int64) (bool, error) {
|
||||
return false, e.err
|
||||
}
|
||||
@@ -25,8 +25,10 @@ type DocAccessDecision struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// CheckDocTaskAccess 对 live 治理实例下的文档任务做 fail-closed 判定。
|
||||
// 无 live 实例时不限制(legacy 兼容)。
|
||||
// CheckDocTaskAccess 对治理实例下的文档任务做判定。
|
||||
//
|
||||
// GetGoverningInstanceForAccess:从未激活 → 允许(legacy);completed/terminated → InstanceClosed;
|
||||
// 存储错误 fail-closed;live 下对非职责/非活动阶段拒绝 run/edit。
|
||||
func (s *Service) CheckDocTaskAccess(
|
||||
ctx context.Context,
|
||||
projectID int64,
|
||||
@@ -40,11 +42,14 @@ func (s *Service) CheckDocTaskAccess(
|
||||
return allow
|
||||
}
|
||||
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
gov, err := s.store.GetGoverningInstanceForAccess(ctx, projectID)
|
||||
if err != nil {
|
||||
return DocAccessDecision{Allowed: false, Code: AccessCodeInstanceClosed, Message: "工作流实例不可用"}
|
||||
}
|
||||
if gov == nil {
|
||||
return allow
|
||||
}
|
||||
if live.Status == InstanceCompleted || live.Status == "terminated" {
|
||||
if IsClosedInstanceStatus(gov.Status) {
|
||||
return DocAccessDecision{
|
||||
Allowed: false,
|
||||
Code: AccessCodeInstanceClosed,
|
||||
@@ -52,7 +57,7 @@ func (s *Service) CheckDocTaskAccess(
|
||||
}
|
||||
}
|
||||
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
detail, err := s.store.GetInstanceByID(ctx, gov.ID)
|
||||
if err != nil || detail == nil {
|
||||
return DocAccessDecision{Allowed: false, Code: AccessCodeInstanceClosed, Message: "工作流实例不可用"}
|
||||
}
|
||||
@@ -98,7 +103,7 @@ func (s *Service) CheckDocTaskAccess(
|
||||
return allow
|
||||
}
|
||||
|
||||
planTask := findDocTaskPlan(live.ConfigSnapshot, taskKey)
|
||||
planTask := findDocTaskPlan(gov.ConfigSnapshot, taskKey)
|
||||
if planTask == nil || !planTask.Enabled {
|
||||
return DocAccessDecision{
|
||||
Allowed: false,
|
||||
|
||||
@@ -12,8 +12,13 @@ type StageAccess struct {
|
||||
AllowedTaskKeys map[string]bool `json:"allowed_task_keys,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveStageAccess 基于 live 实例与三权指派计算阶段 ABAC 范围。
|
||||
// 无 live 实例时不限制(legacy 项目兼容)。
|
||||
// ResolveStageAccess 基于治理实例与三权指派计算阶段 ABAC 范围。
|
||||
//
|
||||
// GetGoverningInstanceForAccess 语义(与 GetLiveInstance/HasLiveInstance 分离):
|
||||
// - (nil, nil):从未激活(无实例或仅 planning)→ 不限制(legacy / 新项目预激活)
|
||||
// - live 系:按职责/创建者 fail-closed 收窄
|
||||
// - completed/terminated:Restricted=true 全拒(不得误判为从未激活)
|
||||
// - 存储错误:上抛,由调用方 fail-closed
|
||||
func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userID int64, isCreator bool) (*StageAccess, error) {
|
||||
out := &StageAccess{
|
||||
AllowedStageKeys: make(map[string]bool),
|
||||
@@ -23,17 +28,19 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
return out, nil
|
||||
}
|
||||
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
out.Restricted = true
|
||||
gov, err := s.store.GetGoverningInstanceForAccess(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gov == nil {
|
||||
return out, nil
|
||||
}
|
||||
if live.Status == InstanceCompleted || live.Status == "terminated" {
|
||||
if IsClosedInstanceStatus(gov.Status) {
|
||||
out.Restricted = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
detail, err := s.store.GetInstanceByID(ctx, live.ID)
|
||||
detail, err := s.store.GetInstanceByID(ctx, gov.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -46,7 +53,7 @@ func (s *Service) ResolveStageAccess(ctx context.Context, projectID int64, userI
|
||||
stageStatus[st.StageKey] = st.Status
|
||||
}
|
||||
|
||||
for _, stPlan := range live.ConfigSnapshot.Stages {
|
||||
for _, stPlan := range gov.ConfigSnapshot.Stages {
|
||||
if !stPlan.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -10,25 +11,7 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
mustActivateTestPlan(t, svc, 42, 1)
|
||||
|
||||
bdUser := int64(99)
|
||||
access, err := svc.ResolveStageAccess(ctx, 42, bdUser, false)
|
||||
@@ -57,3 +40,194 @@ func TestResolveStageAccess_ActiveStageDutyOnly(t *testing.T) {
|
||||
t.Fatal("user without duty should not access active stage chat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStageAccess_NoLiveInstanceUnrestricted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 6071, LifecyclePlanning, "", 86, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
|
||||
creatorAccess, err := svc.ResolveStageAccess(ctx, 6071, 86, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creatorAccess.Restricted {
|
||||
t.Fatal("no governing instance must not restrict (legacy / pre-activation)")
|
||||
}
|
||||
if !creatorAccess.TaskKeyAllowed("intake") {
|
||||
t.Fatal("unrestricted access must allow intake task key")
|
||||
}
|
||||
|
||||
memberAccess, err := svc.ResolveStageAccess(ctx, 6071, 99, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memberAccess.Restricted {
|
||||
t.Fatal("no governing instance must not restrict non-creator either")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStageAccess_LiveOutsiderStillDenied(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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !outsider.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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStageAccess_CompletedInstanceRestricted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 71, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
mustActivateTestPlan(t, svc, 71, 1)
|
||||
|
||||
live, err := store.GetLiveInstance(ctx, 71)
|
||||
if err != nil || live == nil {
|
||||
t.Fatalf("expected live before close: %v", err)
|
||||
}
|
||||
live.Status = InstanceCompleted
|
||||
ms := store
|
||||
ms.mu.Lock()
|
||||
ms.live[71] = live
|
||||
ms.mu.Unlock()
|
||||
|
||||
if has, _ := store.HasLiveInstance(ctx, 71); has {
|
||||
t.Fatal("HasLiveInstance must stay active-only after completed")
|
||||
}
|
||||
gov, err := store.GetGoverningInstanceForAccess(ctx, 71)
|
||||
if err != nil || gov == nil || !IsClosedInstanceStatus(gov.Status) {
|
||||
t.Fatalf("governing must return closed instance, got %+v err=%v", gov, err)
|
||||
}
|
||||
|
||||
access, err := svc.ResolveStageAccess(ctx, 71, 1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !access.Restricted {
|
||||
t.Fatal("completed instance must remain restricted")
|
||||
}
|
||||
if access.TaskKeyAllowed("intake") {
|
||||
t.Fatal("completed instance must deny task access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStageAccess_TerminatedInstanceRestricted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 72, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
mustActivateTestPlan(t, svc, 72, 1)
|
||||
|
||||
live, _ := store.GetLiveInstance(ctx, 72)
|
||||
live.Status = InstanceTerminated
|
||||
ms := store
|
||||
ms.mu.Lock()
|
||||
ms.live[72] = live
|
||||
ms.mu.Unlock()
|
||||
|
||||
access, err := svc.ResolveStageAccess(ctx, 72, 1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !access.Restricted || access.TaskKeyAllowed("intake") {
|
||||
t.Fatalf("terminated must restrict empty allow-list, got %+v", access)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStageAccess_StoreErrorPropagates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(&errGoverningStore{err: errors.New("db down")}, testResolver(), nil, nil)
|
||||
_, err := svc.ResolveStageAccess(ctx, 1, 1, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected store error to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDocTaskAccess_NoGoverningAllows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 80, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
d := svc.CheckDocTaskAccess(ctx, 80, 1, "intake", DocAccessActionRun, true)
|
||||
if !d.Allowed {
|
||||
t.Fatalf("expected allow without governing instance, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDocTaskAccess_CompletedRunDenied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 81, LifecyclePlanning, "", 1, 0)
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
mustActivateTestPlan(t, svc, 81, 1)
|
||||
live, _ := store.GetLiveInstance(ctx, 81)
|
||||
live.Status = InstanceCompleted
|
||||
ms := store
|
||||
ms.mu.Lock()
|
||||
ms.live[81] = live
|
||||
ms.mu.Unlock()
|
||||
|
||||
d := svc.CheckDocTaskAccess(ctx, 81, 1, "intake", DocAccessActionRun, true)
|
||||
if d.Allowed || d.Code != AccessCodeInstanceClosed {
|
||||
t.Fatalf("expected WORKFLOW_INSTANCE_CLOSED, got %+v", d)
|
||||
}
|
||||
dEdit := svc.CheckDocTaskAccess(ctx, 81, 1, "intake", DocAccessActionEdit, true)
|
||||
if dEdit.Allowed || dEdit.Code != AccessCodeInstanceClosed {
|
||||
t.Fatalf("edit on completed must close, got %+v", dEdit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDocTaskAccess_StoreErrorFailClosed(t *testing.T) {
|
||||
svc := NewService(&errGoverningStore{err: errors.New("db down")}, testResolver(), nil, nil)
|
||||
d := svc.CheckDocTaskAccess(context.Background(), 1, 1, "intake", DocAccessActionRun, true)
|
||||
if d.Allowed || d.Code != AccessCodeInstanceClosed {
|
||||
t.Fatalf("store error must fail-closed, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func mustActivateTestPlan(t *testing.T, svc *Service, projectID, userID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := svc.SaveWorkflowPlan(ctx, projectID, userID, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, projectID, userID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
if _, err := svc.Activate(ctx, projectID, userID, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
}); err != nil {
|
||||
t.Fatalf("activate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errGoverningStore 仅覆盖治理查询失败路径。
|
||||
type errGoverningStore struct {
|
||||
Store
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errGoverningStore) GetGoverningInstanceForAccess(context.Context, int64) (*WorkflowInstance, error) {
|
||||
return nil, e.err
|
||||
}
|
||||
|
||||
func (e *errGoverningStore) GetLiveInstance(context.Context, int64) (*WorkflowInstance, error) {
|
||||
return nil, e.err
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ type Store interface {
|
||||
|
||||
GetPlanningInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error)
|
||||
GetLiveInstance(ctx context.Context, projectID int64) (*WorkflowInstance, error)
|
||||
// GetGoverningInstanceForAccess 供阶段/文档 ABAC:优先 live;否则最新 completed/terminated;
|
||||
// 从未激活(无行或仅 planning)返回 (nil, nil)。与 HasLiveInstance(仅 active 系)语义分离。
|
||||
GetGoverningInstanceForAccess(ctx context.Context, projectID int64) (*WorkflowInstance, error)
|
||||
HasLiveInstance(ctx context.Context, projectID int64) (bool, error)
|
||||
UpsertPlanningInstance(ctx context.Context, inst *WorkflowInstance) (*WorkflowInstance, error)
|
||||
ActivateInstance(ctx context.Context, projectID int64, inst *WorkflowInstance, version *workflowdef.Version, expectedVersion int, activatedBy int64) (*WorkflowInstance, error)
|
||||
@@ -159,6 +162,28 @@ func (s *pgStore) GetLiveInstance(ctx context.Context, projectID int64) (*Workfl
|
||||
return inst, err
|
||||
}
|
||||
|
||||
func (s *pgStore) GetGoverningInstanceForAccess(ctx context.Context, projectID int64) (*WorkflowInstance, error) {
|
||||
live, err := s.GetLiveInstance(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if live != nil {
|
||||
return live, nil
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash,
|
||||
config_snapshot, activated_by, activated_at, created_by, created_at, updated_at
|
||||
FROM project_workflow_instances
|
||||
WHERE project_id = $1 AND status IN ('completed', 'terminated')
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`, projectID)
|
||||
inst, err := scanInstance(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return inst, err
|
||||
}
|
||||
|
||||
func (s *pgStore) HasLiveInstance(ctx context.Context, projectID int64) (bool, error) {
|
||||
var ok bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
@@ -745,17 +770,33 @@ func (m *memStore) GetLiveInstance(_ context.Context, projectID int64) (*Workflo
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst := m.live[projectID]
|
||||
if inst == nil {
|
||||
if inst == nil || !IsLiveInstanceStatus(inst.Status) {
|
||||
return nil, nil
|
||||
}
|
||||
copyInst := *inst
|
||||
return ©Inst, nil
|
||||
}
|
||||
|
||||
func (m *memStore) GetGoverningInstanceForAccess(_ context.Context, projectID int64) (*WorkflowInstance, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst := m.live[projectID]
|
||||
if inst == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if IsLiveInstanceStatus(inst.Status) || IsClosedInstanceStatus(inst.Status) {
|
||||
copyInst := *inst
|
||||
return ©Inst, nil
|
||||
}
|
||||
// planning 等非治理态:视为从未激活
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) HasLiveInstance(_ context.Context, projectID int64) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.live[projectID] != nil, nil
|
||||
inst := m.live[projectID]
|
||||
return inst != nil && IsLiveInstanceStatus(inst.Status), nil
|
||||
}
|
||||
|
||||
func (m *memStore) UpsertPlanningInstance(_ context.Context, inst *WorkflowInstance) (*WorkflowInstance, error) {
|
||||
|
||||
@@ -143,8 +143,26 @@ const (
|
||||
StageSkipped = "skipped"
|
||||
|
||||
InstanceCompleted = "completed"
|
||||
InstanceTerminated = "terminated"
|
||||
InstanceAwaitingStageConfirmation = "awaiting_stage_confirmation"
|
||||
InstanceTerminationRequested = "termination_requested"
|
||||
)
|
||||
|
||||
// IsLiveInstanceStatus 与 GetLiveInstance / HasLiveInstance 的 active 系过滤一致。
|
||||
func IsLiveInstanceStatus(status string) bool {
|
||||
switch status {
|
||||
case InstanceActive, InstanceAwaitingStageConfirmation, InstanceTerminationRequested:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsClosedInstanceStatus completed/terminated — 仍需 ABAC 拒绝,不得当作「从未激活」。
|
||||
func IsClosedInstanceStatus(status string) bool {
|
||||
return status == InstanceCompleted || status == InstanceTerminated
|
||||
}
|
||||
|
||||
// DocTaskInstance 文档任务运行实例。
|
||||
type DocTaskInstance struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
Reference in New Issue
Block a user