fix(vault): enforce team access on document updates

This commit is contained in:
Goon committed 2026-06-09 23:04:59 +07:00
1 parent 59a1773a18
commit 94d7bbc28a
2 files changed
+71

No files matched your search

+7
View File
@@ -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"`
@@ -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) {