feat(abac): gate bizdev agent context by workflow stage duties
Inject stage access gate into bizdev service so task execution, project context reads, and prior docs respect live workflow ABAC. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
f6410508aa
commit
7c5f8deeb1
@@ -39,6 +39,7 @@ type Service struct {
|
||||
projectWorkflow *projectworkflow.Service
|
||||
pool *pgxpool.Pool
|
||||
schemaVersion int64
|
||||
stageGate StageAccessGate
|
||||
}
|
||||
|
||||
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
|
||||
@@ -230,6 +231,14 @@ func (s *Service) ListStages() []StageMeta {
|
||||
}
|
||||
|
||||
func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) {
|
||||
if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, req.TaskKey); err != nil {
|
||||
return &TaskRunResult{
|
||||
TaskKey: req.TaskKey,
|
||||
Success: false,
|
||||
ErrorReason: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
materials, inputManifest, err := s.ResolveMaterials(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -240,7 +249,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
|
||||
// 针对 system_reqs(智能产品经理):若未显式提供 materials,自动抽取项目事实黑板/集体上下文为需求输入
|
||||
if req.TaskKey == "system_reqs" && len(materials) == 0 && s.ctxMgr != nil && req.ProjectID > 0 {
|
||||
coll, err := s.ctxMgr.GetProjectContext(ctx, req.ProjectID)
|
||||
coll, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID)
|
||||
if err == nil && coll != nil {
|
||||
var b strings.Builder
|
||||
customer := coll.BlackboardFacts["customer_name"]
|
||||
@@ -342,9 +351,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
if req.TaskKey == "test_manager" {
|
||||
upstreamDocID := fmt.Sprintf("doc_proj%d_system_reqs", req.ProjectID)
|
||||
if s.docGov != nil && req.ProjectID > 0 {
|
||||
doc, err := s.docGov.GetDocument(ctx, upstreamDocID)
|
||||
if err == nil && doc != nil && doc.CurrentState == docgov.StateApproved {
|
||||
upstreamSRDoc = doc
|
||||
if gateErr := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, "system_reqs"); gateErr == nil {
|
||||
doc, err := s.docGov.GetDocument(ctx, upstreamDocID)
|
||||
if err == nil && doc != nil && doc.CurrentState == docgov.StateApproved {
|
||||
upstreamSRDoc = doc
|
||||
}
|
||||
}
|
||||
}
|
||||
if upstreamSRDoc == nil {
|
||||
@@ -592,6 +603,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
}
|
||||
}
|
||||
|
||||
priorDocs := req.PriorDocs
|
||||
if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 {
|
||||
priorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, priorDocs)
|
||||
}
|
||||
|
||||
squadReq := squad.SquadRunRequest{
|
||||
TaskKey: req.TaskKey,
|
||||
Title: req.Title,
|
||||
@@ -608,7 +624,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
CustomSteps: req.CustomSteps,
|
||||
AutoRepair: req.AutoRepair,
|
||||
KnowledgeHits: hits,
|
||||
PriorDocs: req.PriorDocs,
|
||||
PriorDocs: priorDocs,
|
||||
UserContext: userCtxStr,
|
||||
MemoryBankFiles: memBank,
|
||||
SessionID: req.SessionID,
|
||||
@@ -697,6 +713,14 @@ func (s *Service) ExecuteStage(ctx context.Context, req StageRunRequest) (*Stage
|
||||
func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) (*StageRunResult, error) {
|
||||
start := time.Now()
|
||||
|
||||
if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, string(req.Stage)); err != nil {
|
||||
return &StageRunResult{
|
||||
Stage: req.Stage,
|
||||
Success: false,
|
||||
ErrorReason: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
// 1. 确定工作区目录基准
|
||||
stageKey := string(req.Stage)
|
||||
if stageKey == "" {
|
||||
@@ -732,7 +756,7 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest)
|
||||
|
||||
// 若存在项目协同管理器,优先载入已沉淀的项目事实与黑板
|
||||
if s.ctxMgr != nil && req.ProjectID > 0 {
|
||||
if projCtx, err := s.ctxMgr.GetProjectContext(ctx, req.ProjectID); err == nil && projCtx != nil {
|
||||
if projCtx, err := s.getProjectContextForUser(ctx, req.ProjectID, req.UserID); err == nil && projCtx != nil {
|
||||
for k, v := range projCtx.BlackboardFacts {
|
||||
currentFields[k] = v
|
||||
}
|
||||
@@ -761,6 +785,9 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest)
|
||||
}
|
||||
|
||||
accumulatedPriorDocs := append([]squad.PriorDocItem{}, req.PriorDocs...)
|
||||
if s.stageGate != nil && req.ProjectID > 0 && req.UserID > 0 {
|
||||
accumulatedPriorDocs = s.stageGate.FilterPriorDocs(ctx, req.ProjectID, req.UserID, accumulatedPriorDocs)
|
||||
}
|
||||
var executedArtifacts []*TaskRunResult
|
||||
|
||||
// 4. 按顺序执行当前阶段的子任务序列,并将每一轮产出作为后续任务的 PriorDoc 和事实补充
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
var ErrStageAccessDenied = errors.New("bizdev: workflow stage access denied")
|
||||
|
||||
// StageAccessGate 工作流阶段 ABAC 门禁(由 server 注入 projectinstance 实现)。
|
||||
type StageAccessGate interface {
|
||||
AllowTaskExecution(ctx context.Context, projectID, userID int64, taskKey string) error
|
||||
AllowProjectContextRead(ctx context.Context, projectID, userID int64) error
|
||||
FilterProjectContext(ctx context.Context, projectID, userID int64, pCtx *ctxmgr.ProjectCollectiveContext) *ctxmgr.ProjectCollectiveContext
|
||||
FilterPriorDocs(ctx context.Context, projectID, userID int64, docs []squad.PriorDocItem) []squad.PriorDocItem
|
||||
}
|
||||
|
||||
func (s *Service) SetStageAccessGate(gate StageAccessGate) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.stageGate = gate
|
||||
}
|
||||
|
||||
func (s *Service) getProjectContextForUser(ctx context.Context, projectID, userID int64) (*ctxmgr.ProjectCollectiveContext, error) {
|
||||
if s == nil || s.ctxMgr == nil || projectID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
coll, err := s.ctxMgr.GetProjectContext(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.stageGate == nil || userID <= 0 {
|
||||
return coll, nil
|
||||
}
|
||||
if err := s.stageGate.AllowProjectContextRead(ctx, projectID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.stageGate.FilterProjectContext(ctx, projectID, userID, coll), nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureTaskStageAccess(ctx context.Context, projectID, userID int64, taskKey string) error {
|
||||
if s == nil || s.stageGate == nil || projectID <= 0 || userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.stageGate.AllowTaskExecution(ctx, projectID, userID, taskKey)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
type mockStageGate struct {
|
||||
taskErr error
|
||||
ctxErr error
|
||||
filterCtx *ctxmgr.ProjectCollectiveContext
|
||||
filtered []squad.PriorDocItem
|
||||
}
|
||||
|
||||
func (m *mockStageGate) AllowTaskExecution(_ context.Context, _, _ int64, _ string) error {
|
||||
return m.taskErr
|
||||
}
|
||||
|
||||
func (m *mockStageGate) AllowProjectContextRead(_ context.Context, _, _ int64) error {
|
||||
return m.ctxErr
|
||||
}
|
||||
|
||||
func (m *mockStageGate) FilterProjectContext(_ context.Context, _, _ int64, pCtx *ctxmgr.ProjectCollectiveContext) *ctxmgr.ProjectCollectiveContext {
|
||||
if m.filterCtx != nil {
|
||||
return m.filterCtx
|
||||
}
|
||||
return pCtx
|
||||
}
|
||||
|
||||
func (m *mockStageGate) FilterPriorDocs(_ context.Context, _, _ int64, docs []squad.PriorDocItem) []squad.PriorDocItem {
|
||||
if m.filtered != nil {
|
||||
return m.filtered
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func TestEnsureTaskStageAccess_Denied(t *testing.T) {
|
||||
svc := &Service{stageGate: &mockStageGate{taskErr: ErrStageAccessDenied}}
|
||||
err := svc.ensureTaskStageAccess(context.Background(), 1, 2, "intake")
|
||||
if !errors.Is(err, ErrStageAccessDenied) {
|
||||
t.Fatalf("expected ErrStageAccessDenied, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStageAccessGate_NilSafe(t *testing.T) {
|
||||
var svc *Service
|
||||
svc.SetStageAccessGate(&mockStageGate{})
|
||||
s := &Service{}
|
||||
s.SetStageAccessGate(&mockStageGate{filtered: []squad.PriorDocItem{{DocKey: "intake"}}})
|
||||
if s.stageGate == nil {
|
||||
t.Fatal("expected gate to be set")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
type bizdevStageGate struct {
|
||||
instSvc *projectinstance.Service
|
||||
pwSvc interface {
|
||||
IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error)
|
||||
}
|
||||
}
|
||||
|
||||
func newBizdevStageGate(instSvc *projectinstance.Service, pwSvc interface {
|
||||
IsProjectCreator(ctx context.Context, projectID int64, userID int64) (bool, error)
|
||||
}) bizdev.StageAccessGate {
|
||||
if instSvc == nil || !instSvc.Available() {
|
||||
return nil
|
||||
}
|
||||
return &bizdevStageGate{instSvc: instSvc, pwSvc: pwSvc}
|
||||
}
|
||||
|
||||
func (g *bizdevStageGate) access(ctx context.Context, projectID, userID int64) (*projectinstance.StageAccess, error) {
|
||||
if g == nil || g.instSvc == nil || !g.instSvc.Available() || projectID <= 0 || userID <= 0 {
|
||||
return &projectinstance.StageAccess{}, nil
|
||||
}
|
||||
isCreator := false
|
||||
if g.pwSvc != nil {
|
||||
isCreator, _ = g.pwSvc.IsProjectCreator(ctx, projectID, userID)
|
||||
}
|
||||
return g.instSvc.ResolveStageAccess(ctx, projectID, userID, isCreator)
|
||||
}
|
||||
|
||||
func (g *bizdevStageGate) AllowTaskExecution(ctx context.Context, projectID, userID int64, taskKey string) error {
|
||||
access, err := g.access(ctx, projectID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return nil
|
||||
}
|
||||
if taskKey != "" && access.TaskKeyAllowed(taskKey) {
|
||||
return nil
|
||||
}
|
||||
if access.StageKeyAllowed(access.ActiveStageKey) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: task %q", bizdev.ErrStageAccessDenied, taskKey)
|
||||
}
|
||||
|
||||
func (g *bizdevStageGate) AllowProjectContextRead(ctx context.Context, projectID, userID int64) error {
|
||||
access, err := g.access(ctx, projectID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if access == nil || !access.Restricted {
|
||||
return nil
|
||||
}
|
||||
if len(access.AllowedStageKeys) == 0 {
|
||||
return bizdev.ErrStageAccessDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *bizdevStageGate) FilterProjectContext(ctx context.Context, projectID, userID int64, pCtx *ctxmgr.ProjectCollectiveContext) *ctxmgr.ProjectCollectiveContext {
|
||||
if pCtx == nil {
|
||||
return pCtx
|
||||
}
|
||||
access, err := g.access(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
return pCtx
|
||||
}
|
||||
out := *pCtx
|
||||
out.AllActivities = filterActivitiesByStage(access, pCtx.AllActivities)
|
||||
out.MilestoneDecisions = filterActivitiesByStage(access, pCtx.MilestoneDecisions)
|
||||
return &out
|
||||
}
|
||||
|
||||
func filterActivitiesByStage(access *projectinstance.StageAccess, items []ctxmgr.ActivityItem) []ctxmgr.ActivityItem {
|
||||
if access == nil || !access.Restricted {
|
||||
return items
|
||||
}
|
||||
out := make([]ctxmgr.ActivityItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
stageKey := ""
|
||||
if it.Detail != nil {
|
||||
if sk, ok := it.Detail["stage_key"].(string); ok {
|
||||
stageKey = sk
|
||||
}
|
||||
}
|
||||
if stageKey == "" || access.StageKeyAllowed(stageKey) {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *bizdevStageGate) FilterPriorDocs(ctx context.Context, projectID, userID int64, docs []squad.PriorDocItem) []squad.PriorDocItem {
|
||||
if len(docs) == 0 {
|
||||
return docs
|
||||
}
|
||||
access, err := g.access(ctx, projectID, userID)
|
||||
if err != nil || access == nil || !access.Restricted {
|
||||
return docs
|
||||
}
|
||||
out := make([]squad.PriorDocItem, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
if d.DocKey != "" && access.TaskKeyAllowed(d.DocKey) {
|
||||
out = append(out, d)
|
||||
continue
|
||||
}
|
||||
if d.Stage != "" && access.StageKeyAllowed(d.Stage) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -102,6 +102,9 @@ func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
|
||||
notifySvc: notification.NewService(pool),
|
||||
resolver: workspace.NewResolver(cfg.DataDir, pool),
|
||||
}
|
||||
if instSvc != nil && instSvc.Available() {
|
||||
svc.SetStageAccessGate(newBizdevStageGate(instSvc, pwSvc))
|
||||
}
|
||||
s.registerWorkflowApprovalHook()
|
||||
s.startNotificationReminderLoop()
|
||||
s.initTaskRunExecutors()
|
||||
|
||||
Reference in New Issue
Block a user