fix(docgov): assert edit duty before lease; project via SaveDocumentContent
Unauthorized actors no longer grab leases; controlled PATCH projects through DocGov so author_last_edited and audit survive, with idempotent retry on failure. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+17
-13
@@ -287,28 +287,32 @@ func (e *GovernanceEngine) MarkProposalApplied(ctx context.Context, proposalID s
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssertHumanCanEdit acquires/renews the human lease and checks author duty + drafting lifecycle
|
||||
// without writing document body. Used by revision PATCH before ledger mutation.
|
||||
// AssertHumanCanEdit checks author duty + drafting lifecycle BEFORE acquiring/renewing
|
||||
// the human lease, so unauthorized actors never become lease holders.
|
||||
func (e *GovernanceEngine) AssertHumanCanEdit(ctx context.Context, docID string, actorID int64, actorRole UserRole) error {
|
||||
e.mu.Lock()
|
||||
doc, err := e.getDocLocked(ctx, docID)
|
||||
if err != nil {
|
||||
e.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
if err := e.sm.CanEditDocumentContent(doc, actorID, actorRole); err != nil {
|
||||
e.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
holderID := fmt.Sprintf("%d", actorID)
|
||||
if actorID <= 0 {
|
||||
holderID = "human_anon"
|
||||
}
|
||||
if _, err := e.AcquireLease(ctx, LeaseAcquireRequest{
|
||||
_, err = e.AcquireLease(ctx, LeaseAcquireRequest{
|
||||
DocID: docID,
|
||||
HolderType: LeaseHolderHuman,
|
||||
HolderID: holderID,
|
||||
TTL: DefaultHumanLeaseTTL,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
doc, err := e.getDocLocked(ctx, docID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.sm.CanEditDocumentContent(doc, actorID, actorRole)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveDocumentContent 人工正文保存:校验可编辑 + 获取/续 human 租约 + 乐观哈希 + 落盘
|
||||
|
||||
@@ -85,3 +85,37 @@ func TestSameHolderRenewLease(t *testing.T) {
|
||||
t.Fatalf("expected renewed TTL, expires=%v", doc.LeaseExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertHumanCanEdit_ChecksDutyBeforeLease(t *testing.T) {
|
||||
engine := docgov.NewGovernanceEngine(nil)
|
||||
ctx := context.Background()
|
||||
docID := "doc_assert_order_001"
|
||||
_, err := engine.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "",
|
||||
1, "作者", 2, "审核", 3, "批准")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Non-author must fail without becoming lease holder.
|
||||
if err := engine.AssertHumanCanEdit(ctx, docID, 99, docgov.RoleAuthor); err == nil {
|
||||
t.Fatal("non-author AssertHumanCanEdit must fail")
|
||||
}
|
||||
doc, err := engine.GetDocument(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.HasActiveLease(time.Now()) && doc.LeaseHolderID == "99" {
|
||||
t.Fatal("unauthorized actor must not hold human lease")
|
||||
}
|
||||
|
||||
if err := engine.AssertHumanCanEdit(ctx, docID, 1, docgov.RoleAuthor); err != nil {
|
||||
t.Fatalf("author AssertHumanCanEdit: %v", err)
|
||||
}
|
||||
doc, err = engine.GetDocument(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !doc.HasActiveLease(time.Now()) || doc.LeaseHolderType != docgov.LeaseHolderHuman || doc.LeaseHolderID != "1" {
|
||||
t.Fatalf("author should hold human lease after assert, got type=%s id=%s", doc.LeaseHolderType, doc.LeaseHolderID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,9 +238,26 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
// 3) Project fact-source AFTER ledger commit. Projection failure returns error (no fake 200).
|
||||
// Ledger remains authoritative; client should retry projection via re-save or admin repair.
|
||||
if kind == "controlled" || workDir != "" {
|
||||
// 3) Project fact-source AFTER ledger commit. Controlled docs use SaveDocumentContent so
|
||||
// author_last_edited / audit trail stay consistent; failure → DOCUMENT_PROJECTION_FAILED
|
||||
// (ledger remains authoritative; same Idempotency-Key retry re-projects).
|
||||
if kind == "controlled" && s.svc != nil && s.svc.DocGov() != nil {
|
||||
if _, err := s.svc.DocGov().SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
|
||||
DocID: docID,
|
||||
ActorID: user.ID,
|
||||
ActorRole: docgov.RoleAuthor,
|
||||
ActorName: user.Name,
|
||||
MarkdownBody: body.Markdown,
|
||||
}); err != nil {
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
||||
"code": "DOCUMENT_PROJECTION_FAILED",
|
||||
"error": err.Error(),
|
||||
"hint": "账本已提交但 DocGov 正文/审计投影失败;请用同一 Idempotency-Key 重试保存。账本 head 仍为权威。",
|
||||
"state": res.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
} else if workDir != "" {
|
||||
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
||||
"code": "DOCUMENT_PROJECTION_FAILED",
|
||||
@@ -250,17 +267,6 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
|
||||
})
|
||||
return
|
||||
}
|
||||
if s.svc != nil && s.svc.DocGov() != nil {
|
||||
if err := s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false); err != nil {
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
||||
"code": "DOCUMENT_PROJECTION_FAILED",
|
||||
"error": err.Error(),
|
||||
"hint": "账本已提交但 document_records 哈希投影失败;请重试保存。",
|
||||
"state": res.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == "freeform" && s.svc != nil && s.svc.FreeformDoc() != nil {
|
||||
md := body.Markdown
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
@@ -19,7 +20,7 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger(
|
||||
ctx := context.Background()
|
||||
dataRoot := srv.dataRoot()
|
||||
|
||||
// WorkDir whose parent is a regular file → MkdirAll fails after ledger commit.
|
||||
// WorkDir whose parent is a regular file → SaveDocumentContent MkdirAll fails after ledger commit.
|
||||
blocker := filepath.Join(dataRoot, "proj-block")
|
||||
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -100,4 +101,53 @@ func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
|
||||
if after.HeadRevisionNo != before {
|
||||
t.Fatalf("ledger must not advance when AssertHumanCanEdit fails: before=%d after=%d", before, after.HeadRevisionNo)
|
||||
}
|
||||
doc, err := srv.svc.DocGov().GetDocument(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.HasActiveLease(time.Now()) && doc.LeaseHolderID == "1" {
|
||||
t.Fatal("failed AssertHumanCanEdit must not leave non-author admin holding the lease")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
workDir := filepath.Join(srv.dataRoot(), "ws-audit")
|
||||
_ = os.MkdirAll(workDir, 0o755)
|
||||
docID := "doc_proj1_patch_audit"
|
||||
|
||||
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token := loginTestAdmin(t, srv)
|
||||
md := "# base\n\nbody\n\nhuman edit\n"
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
|
||||
bytes.NewReader([]byte(`{"markdown":`+strconv.Quote(md)+`}`)))
|
||||
withBearerAuth(req, token)
|
||||
req.Header.Set("Idempotency-Key", "patch-audit-k1")
|
||||
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
doc, err := srv.svc.DocGov().GetDocument(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.AuthorLastEditedAt == nil {
|
||||
t.Fatal("SaveDocumentContent projection must set author_last_edited_at")
|
||||
}
|
||||
if !doc.AuthorClaimed {
|
||||
t.Fatal("author_claimed should be true after human save projection")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil {
|
||||
t.Fatalf("output.md should exist after SaveDocumentContent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user