From 94d7bbc28abb8fcaf57c88c90c74bdede7d617cc Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 9 Jun 2026 23:04:59 +0700 Subject: [PATCH] fix(vault): enforce team access on document updates --- internal/http/vault_handler_documents.go | 7 ++ internal/http/vault_handler_documents_test.go | 64 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/internal/http/vault_handler_documents.go b/internal/http/vault_handler_documents.go index e6ef22e7..825448b0 100644 --- a/internal/http/vault_handler_documents.go +++ b/internal/http/vault_handler_documents.go @@ -382,6 +382,13 @@ func (h *VaultHandler) handleUpdateDocument(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"}) return } + // Verify team boundary before any metadata or content rewrite. PUT can + // materialize bytes on disk, so it must match GET/DELETE team access rules. + if existing.TeamID != nil && *existing.TeamID != "" && !store.IsOwnerRole(r.Context()) { + if !h.validateTeamMembership(r.Context(), w, *existing.TeamID) { + return + } + } var body struct { Title *string `json:"title"` diff --git a/internal/http/vault_handler_documents_test.go b/internal/http/vault_handler_documents_test.go index fa593f2b..a740e28a 100644 --- a/internal/http/vault_handler_documents_test.go +++ b/internal/http/vault_handler_documents_test.go @@ -180,6 +180,26 @@ func (b *fakeEventBus) Subscribe(eventbus.EventType, eventbus.DomainEventHandler func (b *fakeEventBus) Start(context.Context) {} func (b *fakeEventBus) Drain(time.Duration) error { return nil } +type fakeTeamAccessStore struct { + allowed bool +} + +func (f *fakeTeamAccessStore) GrantTeamAccess(context.Context, uuid.UUID, string, string, string) error { + return nil +} +func (f *fakeTeamAccessStore) RevokeTeamAccess(context.Context, uuid.UUID, string) error { + return nil +} +func (f *fakeTeamAccessStore) ListTeamGrants(context.Context, uuid.UUID) ([]store.TeamUserGrant, error) { + return nil, nil +} +func (f *fakeTeamAccessStore) ListUserTeams(context.Context, string) ([]store.TeamData, error) { + return nil, nil +} +func (f *fakeTeamAccessStore) HasTeamAccess(context.Context, uuid.UUID, string) (bool, error) { + return f.allowed, nil +} + func newJSONRequest(t *testing.T, method, target string, body any) *http.Request { t.Helper() buf, err := json.Marshal(body) @@ -381,6 +401,50 @@ func TestHandleUpdateDocument_NilContentSkipsWriteAndEvent(t *testing.T) { } } +func TestHandleUpdateDocument_TeamDocRequiresMembershipBeforeContentWrite(t *testing.T) { + teamID := uuid.NewString() + st := newFakeVaultStore() + st.docs["team-doc"] = &store.VaultDocument{ + ID: "team-doc", + TenantID: masterTenant.String(), + TeamID: &teamID, + Path: "teams/" + teamID + "/private.md", + Title: "Private", + DocType: "note", + Scope: "team", + } + bus := &fakeEventBus{store: st} + ws := t.TempDir() + h := &VaultHandler{ + store: st, + teamAccess: &fakeTeamAccessStore{allowed: false}, + eventBus: bus, + workspace: ws, + } + + ctx := store.WithUserID(masterCtx(), "non-member") + req := newJSONRequest(t, http.MethodPut, "/v1/vault/documents/team-doc", map[string]any{ + "content": "should not be written", + }).WithContext(ctx) + req.SetPathValue("docID", "team-doc") + rr := httptest.NewRecorder() + + h.handleUpdateDocument(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (body: %s)", rr.Code, rr.Body.String()) + } + if _, statErr := os.Stat(filepath.Join(ws, "teams", teamID, "private.md")); statErr == nil { + t.Fatal("non-member content update wrote file before team access check") + } + if st.upsertCall != 0 { + t.Fatalf("upsertCall = %d, want 0", st.upsertCall) + } + if len(bus.published) != 0 { + t.Fatalf("published %d events, want 0", len(bus.published)) + } +} + // Handler-level guard at vault_handler_documents.go path validation must reject // "..", short-circuiting before writeDocumentContent even runs. func TestHandleCreateDocument_RejectsParentTraversalPath(t *testing.T) {