diff --git a/internal/app/docflow/commands_test.go b/internal/app/docflow/commands_test.go index 7a08648f..9179a15c 100644 --- a/internal/app/docflow/commands_test.go +++ b/internal/app/docflow/commands_test.go @@ -145,6 +145,20 @@ func TestIdempotentHumanEditSameDigest(t *testing.T) { } } +func TestDutyAuthorizerAllowsDocumentAuthorCommit(t *testing.T) { + rec := &docgov.DocumentRecord{DocID: "doc_flow_author_commit", AuthorID: 4, ApproverID: 9} + auth := DutyAuthorizer() + if err := auth.Authorize(context.Background(), Principal{UserID: 4}, rec, ActionCommit); err != nil { + t.Fatalf("document author must pass agent commit: %v", err) + } + if err := auth.Authorize(context.Background(), Principal{UserID: 74}, rec, ActionCommit); err != uow.ErrPermissionDenied { + t.Fatalf("non-author commit must be denied, got %v", err) + } + if err := auth.Authorize(context.Background(), Principal{UserID: 4}, rec, ActionEdit); err != nil { + t.Fatalf("document author must still pass edit: %v", err) + } +} + func TestReauthDeniesWrongAuthor(t *testing.T) { ctx := context.Background() gov := docgov.NewGovernanceEngine(nil) diff --git a/internal/bizdev/service_task_finalize.go b/internal/bizdev/service_task_finalize.go index 761f5763..5cbecb6e 100644 --- a/internal/bizdev/service_task_finalize.go +++ b/internal/bizdev/service_task_finalize.go @@ -4,10 +4,12 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "strings" "time" + "chat-agent-system/internal/app/uow" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/projectworkflow" @@ -27,6 +29,17 @@ func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docg return reqAutoRepair } +// squadCommitActor 让受控账本提交通过编制人职责门。 +// 触发者不是编制人时,以文档编制人作为 AutoCommitUserID,并把触发者写入 OnBehalfOf。 +// 触发者或编制人缺失时保持原触发者,避免把 0 写成代理身份。 +func squadCommitActor(triggerUserID int64, docRec *docgov.DocumentRecord) (int64, *int64) { + if docRec == nil || docRec.AuthorID <= 0 || triggerUserID <= 0 || docRec.AuthorID == triggerUserID { + return triggerUserID, nil + } + onBehalf := triggerUserID + return docRec.AuthorID, &onBehalf +} + func qualityFailedErrorReason(report *squad.QualityReport) string { if report == nil { return "质量门禁未通过" @@ -87,15 +100,20 @@ func (s *Service) finalizeSquadDeliverable( // Squad may have already written candidate body to workDir/output.md — pending must restore ledger head. if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" { actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} + commitUserID, onBehalfOf := squadCommitActor(req.UserID, docRec) mat, matErr := s.materializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey), ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody, ProjectID: req.ProjectID, WorkDir: res.WorkDir, ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor, - AutoCommitUserID: req.UserID, + OnBehalfOf: onBehalfOf, + AutoCommitUserID: commitUserID, }) if matErr != nil { reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr) + if errors.Is(matErr, uow.ErrPermissionDenied) { + reason = fmt.Sprintf("触发用户非文档编制人,账本提交被拒: %v", matErr) + } return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, diff --git a/internal/bizdev/squad_commit_actor_test.go b/internal/bizdev/squad_commit_actor_test.go new file mode 100644 index 00000000..88c66c86 --- /dev/null +++ b/internal/bizdev/squad_commit_actor_test.go @@ -0,0 +1,178 @@ +package bizdev + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "chat-agent-system/internal/app/docflow" + "chat-agent-system/internal/app/uow" + "chat-agent-system/internal/config" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/docrevision" +) + +type recordingMaterializer struct { + inner AgentOutputMaterializer + got docrevision.AgentMaterializeRequest + err error +} + +func (r *recordingMaterializer) MaterializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) { + r.got = req + if r.err != nil { + return nil, r.err + } + if r.inner == nil { + return nil, errors.New("recording materializer has no inner") + } + return r.inner.MaterializeAgentOutput(ctx, req) +} + +func TestSquadCommitActor_UsesAuthorWhenTriggerDiffers(t *testing.T) { + author, behalf := squadCommitActor(74, &docgov.DocumentRecord{AuthorID: 4}) + if author != 4 { + t.Fatalf("commit actor want 4 got %d", author) + } + if behalf == nil || *behalf != 74 { + t.Fatalf("on behalf want 74 got %v", behalf) + } + same, behalfSame := squadCommitActor(4, &docgov.DocumentRecord{AuthorID: 4}) + if same != 4 || behalfSame != nil { + t.Fatalf("author trigger must not proxy: actor=%d behalf=%v", same, behalfSame) + } + missing, behalfMissing := squadCommitActor(74, &docgov.DocumentRecord{}) + if missing != 74 || behalfMissing != nil { + t.Fatalf("unset author must keep trigger: actor=%d behalf=%v", missing, behalfMissing) + } +} + +func TestExecuteTask_NonAuthorTriggerMaterializesAsAuthor(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + const ( + projectID = int64(6022) + authorID = int64(4) + triggerID = int64(74) + docID = "doc_proj6022_bd_intake" + ) + if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", authorID, "admin"); err != nil { + t.Fatal(err) + } + rec := &recordingMaterializer{inner: revMaterializer{rev: svc.docRev}} + svc.SetAgentMaterializer(rec) + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "intake", ProjectID: projectID, ProjectName: "测试项目", + CustomerName: "C", DeviceType: "医疗器械", Backend: "stub", + Timeout: 10 * time.Second, DocID: docID, UserID: triggerID, + }) + if err != nil { + t.Fatalf("ExecuteTask: %v reason=%v", err, res) + } + if res == nil || !res.Success { + t.Fatalf("non-author trigger must succeed, got %#v", res) + } + if rec.got.AutoCommitUserID != authorID { + t.Fatalf("AutoCommitUserID want %d got %d", authorID, rec.got.AutoCommitUserID) + } + if rec.got.OnBehalfOf == nil || *rec.got.OnBehalfOf != triggerID { + t.Fatalf("OnBehalfOf want %d got %v", triggerID, rec.got.OnBehalfOf) + } +} + +func TestExecuteTask_AuthorTriggerDoesNotSetOnBehalfOf(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + const ( + projectID = int64(6023) + authorID = int64(4) + docID = "doc_proj6023_bd_intake" + ) + if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", 0, ""); err != nil { + t.Fatal(err) + } + rec := &recordingMaterializer{inner: revMaterializer{rev: svc.docRev}} + svc.SetAgentMaterializer(rec) + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "intake", ProjectID: projectID, ProjectName: "P", + CustomerName: "C", DeviceType: "医疗器械", Backend: "stub", + Timeout: 10 * time.Second, DocID: docID, UserID: authorID, + }) + if err != nil || res == nil || !res.Success { + t.Fatalf("author trigger failed: err=%v res=%#v", err, res) + } + if rec.got.AutoCommitUserID != authorID || rec.got.OnBehalfOf != nil { + t.Fatalf("author path changed: user=%d behalf=%v", rec.got.AutoCommitUserID, rec.got.OnBehalfOf) + } +} + +func TestExecuteTask_NonAuthorPassesDutyAuthorizer(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + const ( + projectID = int64(6024) + authorID = int64(4) + triggerID = int64(74) + docID = "doc_proj6024_bd_intake" + ) + if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", 0, ""); err != nil { + t.Fatal(err) + } + svc.SetAgentMaterializer(&docflow.Commands{Gov: svc.docGov, Rev: svc.docRev, Auth: docflow.DutyAuthorizer()}) + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "intake", ProjectID: projectID, ProjectName: "P", + CustomerName: "C", DeviceType: "医疗器械", Backend: "stub", + Timeout: 10 * time.Second, DocID: docID, UserID: triggerID, + }) + if err != nil || res == nil || !res.Success { + t.Fatalf("duty authorizer must accept author-proxy commit: err=%v reason=%v", err, errorReason(res)) + } + if strings.Contains(errorReason(res), "permission denied") { + t.Fatalf("permission denied leaked: %s", res.ErrorReason) + } +} + +func TestExecuteTask_PermissionDeniedNamesNonAuthor(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + const docID = "doc_perm_denied" + if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, 77, "research", "调研", "V0.5", "", 4, "admin", 0, "", 0, ""); err != nil { + t.Fatal(err) + } + svc.SetAgentMaterializer(&recordingMaterializer{err: uow.ErrPermissionDenied}) + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "research", ProjectID: 77, ProjectName: "P", + CustomerName: "C", DeviceType: "医疗器械", Backend: "stub", + Timeout: 10 * time.Second, DocID: docID, UserID: 74, + }) + if err == nil || res == nil || res.Success { + t.Fatalf("want visible permission failure, err=%v res=%#v", err, res) + } + if !strings.Contains(res.ErrorReason, "触发用户非文档编制人") { + t.Fatalf("reason must name non-author denial, got %q", res.ErrorReason) + } + if !errors.Is(err, uow.ErrPermissionDenied) { + t.Fatalf("sentinel must stay errors.Is compatible, got %v", err) + } +} + +type revMaterializer struct { + rev *docrevision.Service +} + +func (r revMaterializer) MaterializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) { + return r.rev.MaterializeAgentOutput(ctx, req) +} + +func errorReason(res *TaskRunResult) string { + if res == nil { + return "" + } + return res.ErrorReason +}