fix(api): assert edit rights before ledger, then project body

Prevent fact-source writes ahead of ledger commit; projection failures return
DOCUMENT_PROJECTION_FAILED while keeping the ledger authoritative.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 09:27:07 +00:00
co-authored by Cursor
parent a288dcf0cc
commit 75ec8785f3
4 changed files with 140 additions and 21 deletions
+4 -6
View File
@@ -61,9 +61,7 @@ func (s *Server) authorizeDocRevision(ctx context.Context, user *auth.UserAccoun
return authz.ErrForbidden
}
if ref.Kind == "orphan" {
if write {
return authz.ErrForbidden
}
// Unknown doc IDs: never bootstrap a silent fake ledger (read or write).
return docrevision.ErrNotFound
}
if ref.ProjectID > 0 {
@@ -143,6 +141,9 @@ func (s *Server) bootstrapRevisionIfNeeded(ctx context.Context, user *auth.UserA
if !errors.Is(err, docrevision.ErrNotFound) {
return nil, err
}
if ref != nil && ref.Kind == "orphan" {
return nil, docrevision.ErrNotFound
}
md := ""
if ref != nil {
md = ref.Markdown
@@ -158,9 +159,6 @@ func (s *Server) bootstrapRevisionIfNeeded(ctx context.Context, user *auth.UserA
if ref.Kind != "" {
kind = ref.Kind
}
if ref.Kind == "orphan" {
kind = "freeform"
}
life = ref.LifecycleState
projectID = ref.ProjectID
workDir = ref.WorkDir
@@ -57,16 +57,16 @@ func TestDocRevision_UnauthenticatedAndUnknownOrphan(t *testing.T) {
patch.Header.Set("Idempotency-Key", "orphan-k1")
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, patch)
if w.Code != http.StatusForbidden {
t.Fatalf("unknown orphan PATCH want 403 got %d %s", w.Code, w.Body.String())
if w.Code != http.StatusNotFound {
t.Fatalf("unknown orphan PATCH want 404 got %d %s", w.Code, w.Body.String())
}
dec := httptest.NewRequest(http.MethodPost, "/api/v1/documents/doc-upload-unknown/agent-candidates/1/decision", bytes.NewReader([]byte(`{"decision":"reject"}`)))
withBearerAuth(dec, token)
w2 := httptest.NewRecorder()
srv.Routes().ServeHTTP(w2, dec)
if w2.Code != http.StatusForbidden {
t.Fatalf("unknown orphan decision want 403 got %d %s", w2.Code, w2.Body.String())
if w2.Code != http.StatusNotFound {
t.Fatalf("unknown orphan decision want 404 got %d %s", w2.Code, w2.Body.String())
}
}
+29 -11
View File
@@ -188,20 +188,20 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
projectID = ref.ProjectID
kind = ref.Kind
}
if kind == "orphan" {
writeDocRevisionError(w, docrevision.ErrNotFound)
return
}
// 1) Controlled: author duty + lease WITHOUT writing body (fail-closed before ledger).
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 {
if err := s.svc.DocGov().AssertHumanCanEdit(r.Context(), docID, user.ID, docgov.RoleAuthor); err != nil {
writeDocGovError(w, err)
return
}
}
// 2) Bootstrap if needed, then commit ledger. No fact-source mutation yet.
if _, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil {
md := body.Markdown
if ref != nil && strings.TrimSpace(ref.Markdown) != "" {
@@ -224,7 +224,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev,
IdempotencyKey: idemp, EditSessionID: body.EditSessionID, ActorUserID: user.ID,
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"},
LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: true,
LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: false,
ClientBlockMap: body.ClientBlockMap,
})
if errors.Is(err, docrevision.ErrHeadConflict) && res != nil && res.Conflict != nil {
@@ -237,14 +237,27 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
writeDocRevisionError(w, err)
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 != "" {
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
writeDocRevisionError(w, err)
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但工作区投影失败;请重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
if s.svc != nil && s.svc.DocGov() != nil {
if err := s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false); err != nil {
writeDocGovError(w, err)
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但 document_records 哈希投影失败;请重试保存。",
"state": res.State,
})
return
}
}
@@ -255,7 +268,12 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
ContentMarkdown: &md,
ChangeSummary: "revision ledger human save",
}); err != nil {
writeJSONResponse(w, http.StatusConflict, map[string]any{"error": err.Error()})
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
"code": "DOCUMENT_PROJECTION_FAILED",
"error": err.Error(),
"hint": "账本已提交但自由文档正文投影失败;请重试保存。账本 head 仍为权威。",
"state": res.State,
})
return
}
}
@@ -0,0 +1,103 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"chat-agent-system/internal/docrevision"
)
func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
dataRoot := srv.dataRoot()
// WorkDir whose parent is a regular file → 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)
}
workDir := filepath.Join(blocker, "ws-proj")
docID := "doc_proj1_patch_order"
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)
}
beforeRev := st.HeadRevisionNo
beforeSnap := st.HeadSnapshotID
token := loginTestAdmin(t, srv)
body := `{"markdown":"# base\n\nbody\n\nedited by human\n"}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", bytes.NewReader([]byte(body)))
withBearerAuth(req, token)
req.Header.Set("Idempotency-Key", "patch-order-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.StatusInternalServerError {
t.Fatalf("projection failure want 500 got %d body=%s", w.Code, w.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["code"] != "DOCUMENT_PROJECTION_FAILED" {
t.Fatalf("code want DOCUMENT_PROJECTION_FAILED got %#v", payload["code"])
}
after, err := srv.svc.DocRevision().GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
if after.HeadRevisionNo <= beforeRev && after.HeadSnapshotID == beforeSnap {
t.Fatalf("ledger should commit before projection; before rev=%d snap=%d after=%+v", beforeRev, beforeSnap, after)
}
}
func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
srv := newTestServer(t)
ctx := context.Background()
docID := "doc_proj1_lease_denied"
workDir := filepath.Join(srv.dataRoot(), "ws-lease")
_ = os.MkdirAll(workDir, 0o755)
// Author is user 99; login admin may bypass route author gate but AssertHumanCanEdit (RoleAuthor) still fails.
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 99, "other", 2, "r", 3, "a"); err != nil {
t.Fatal(err)
}
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# L\n\nx\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
if err != nil {
t.Fatal(err)
}
before := st.HeadRevisionNo
token := loginTestAdmin(t, srv)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
bytes.NewReader([]byte(`{"markdown":"# L\n\nchanged\n"}`)))
withBearerAuth(req, token)
req.Header.Set("Idempotency-Key", "lease-denied-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("non-author must not save successfully: %s", w.Body.String())
}
after, err := srv.svc.DocRevision().GetRevisionState(ctx, docID)
if err != nil {
t.Fatal(err)
}
if after.HeadRevisionNo != before {
t.Fatalf("ledger must not advance when AssertHumanCanEdit fails: before=%d after=%d", before, after.HeadRevisionNo)
}
}