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:
weichen
2026-09-13 12:25:22 +00:00
co-authored by Cursor
parent cd69cfd4d8
commit f50692be25
6 changed files with 533 additions and 36 deletions
@@ -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
}
+12 -7
View File
@@ -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,
+15 -8
View File
@@ -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
}
+193 -19
View File
@@ -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
}
+43 -2
View File
@@ -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 &copyInst, 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 &copyInst, 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) {
+18
View File
@@ -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"`