Files
goclaw/internal/http/vault_handler_links.go
viettranx 9625a20e8b fix(vault): complete chat_id isolation coverage
- Extend migration 000056 with legacy-docs backfill (telegram/<chat>,
  ws/<chat>, delegate/<chat>, and agent/bot/<chat> path shapes) so
  scope=shared/personal docs created before team-scope refactor also
  get stamped with chat_id and stop leaking across chats
- rescan: inferOwnerFromPath now returns chat_id extracted from team
  path third segment; stamp onto VaultDocument so admin /vault/rescan
  preserves chat isolation
- HTTP vault search: accept optional chat_id body param, set
  TeamIsolated=true when TeamID + ChatID both present (admin UI can
  view vault from a specific chat perspective)

Applied to staging DB via MCP: 91 legacy rows backfilled.
2026-04-23 18:56:57 +07:00

263 lines
8.1 KiB
Go

package http
import (
"log/slog"
"net/http"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleSearchAll runs tenant-wide search (agent_id optional in body).
func (h *VaultHandler) handleSearchAll(w http.ResponseWriter, r *http.Request) {
h.doSearch(w, r, "")
}
// handleSearch runs hybrid FTS+vector search scoped to a specific agent.
func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
h.doSearch(w, r, r.PathValue("agentID"))
}
// doSearch is the shared search implementation for both per-agent and tenant-wide endpoints.
func (h *VaultHandler) doSearch(w http.ResponseWriter, r *http.Request, agentID string) {
locale := extractLocale(r)
tenantID := store.TenantIDFromContext(r.Context())
var body struct {
Query string `json:"query"`
AgentID string `json:"agent_id"`
Scope string `json:"scope"`
DocTypes []string `json:"doc_types"`
MaxResults int `json:"max_results"`
TeamID string `json:"team_id"`
ChatID string `json:"chat_id"` // optional: when set with TeamID, restrict to same-chat + team-wide docs (isolated semantics)
}
if !bindJSON(w, r, locale, &body) {
return
}
if body.Query == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "query is required"})
return
}
if body.MaxResults <= 0 {
body.MaxResults = 10
}
// Body agent_id only used when path doesn't provide one (tenant-wide endpoint).
if agentID == "" {
agentID = body.AgentID
}
searchOpts := store.VaultSearchOptions{
Query: body.Query,
AgentID: agentID,
TenantID: tenantID.String(),
Scope: body.Scope,
DocTypes: body.DocTypes,
MaxResults: body.MaxResults,
}
if body.TeamID != "" {
if !h.validateTeamMembership(r.Context(), w, body.TeamID) {
return
}
searchOpts.TeamID = &body.TeamID
// Caller-supplied chat scope: apply isolation filter when searching a specific chat.
if body.ChatID != "" {
cid := body.ChatID
searchOpts.ChatID = &cid
searchOpts.TeamIsolated = true
}
} else if !store.IsOwnerRole(r.Context()) {
if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 {
searchOpts.TeamIDs = ids
} else {
empty := ""
searchOpts.TeamID = &empty
}
}
results, err := h.store.Search(r.Context(), searchOpts)
if err != nil {
slog.Warn("vault.search failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if results == nil {
results = []store.VaultSearchResult{}
}
writeJSON(w, http.StatusOK, results)
}
// handleGetLinks returns outgoing links and backlinks for a vault document.
func (h *VaultHandler) handleGetLinks(w http.ResponseWriter, r *http.Request) {
tenantID := store.TenantIDFromContext(r.Context())
_ = r.PathValue("agentID") // agent scoping done at document level
docID := r.PathValue("docID")
outLinks, err := h.store.GetOutLinks(r.Context(), tenantID.String(), docID)
if err != nil {
slog.Warn("vault.outlinks failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
backlinks, err := h.store.GetBacklinks(r.Context(), tenantID.String(), docID)
if err != nil {
slog.Warn("vault.backlinks failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if outLinks == nil {
outLinks = []store.VaultLink{}
}
if backlinks == nil {
backlinks = []store.VaultBacklink{}
}
// Filter backlinks by team boundary — derive team context from the target document
// itself (not a query param) so clients don't need to supply it correctly.
isOwner := store.IsOwnerRole(r.Context())
if !isOwner {
targetDoc, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), docID)
var currentTeamID string
if targetDoc != nil && targetDoc.TeamID != nil {
currentTeamID = *targetDoc.TeamID
}
filtered := make([]store.VaultBacklink, 0, len(backlinks))
for _, bl := range backlinks {
if currentTeamID != "" {
if bl.TeamID != nil && *bl.TeamID != currentTeamID {
continue
}
} else {
if bl.TeamID != nil && *bl.TeamID != "" {
continue
}
}
filtered = append(filtered, bl)
}
backlinks = filtered
}
// Resolve target doc titles for outlinks so the UI can display names instead of IDs.
docNames := make(map[string]string, len(outLinks))
for _, l := range outLinks {
if _, seen := docNames[l.ToDocID]; seen {
continue
}
if d, err := h.store.GetDocumentByID(r.Context(), tenantID.String(), l.ToDocID); err == nil && d != nil {
name := d.Title
if name == "" {
if idx := strings.LastIndex(d.Path, "/"); idx >= 0 {
name = d.Path[idx+1:]
} else {
name = d.Path
}
}
docNames[l.ToDocID] = name
}
}
writeJSON(w, http.StatusOK, map[string]any{
"outlinks": outLinks,
"backlinks": backlinks,
"doc_names": docNames,
})
}
// handleCreateLink creates a link between two vault documents.
func (h *VaultHandler) handleCreateLink(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
tenantID := store.TenantIDFromContext(r.Context())
var body struct {
FromDocID string `json:"from_doc_id"`
ToDocID string `json:"to_doc_id"`
LinkType string `json:"link_type"`
Context string `json:"context"`
}
if !bindJSON(w, r, locale, &body) {
return
}
if body.FromDocID == "" || body.ToDocID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from_doc_id and to_doc_id are required"})
return
}
if body.LinkType == "" {
body.LinkType = "reference"
}
// Verify both docs exist, same tenant, and at least source belongs to this agent.
agentID := r.PathValue("agentID")
from, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), body.FromDocID)
to, _ := h.store.GetDocumentByID(r.Context(), tenantID.String(), body.ToDocID)
if from == nil || to == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "one or both documents not found"})
return
}
if agentID != "" && from.AgentID != nil && *from.AgentID != agentID {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "source document does not belong to this agent"})
return
}
// Block cross-team linking (both team docs must be in same team).
if from.TeamID != nil && to.TeamID != nil && *from.TeamID != *to.TeamID {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot link documents from different teams"})
return
}
link := &store.VaultLink{
FromDocID: body.FromDocID,
ToDocID: body.ToDocID,
LinkType: body.LinkType,
Context: body.Context,
}
if err := h.store.CreateLink(r.Context(), link); err != nil {
slog.Warn("vault.create_link failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, link)
}
// handleBatchGetLinks returns all outlinks for a batch of doc IDs in one query.
func (h *VaultHandler) handleBatchGetLinks(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
tenantID := store.TenantIDFromContext(r.Context())
var body struct {
DocIDs []string `json:"doc_ids"`
}
if !bindJSON(w, r, locale, &body) {
return
}
if len(body.DocIDs) == 0 {
writeJSON(w, http.StatusOK, []store.VaultLink{})
return
}
if len(body.DocIDs) > 500 {
body.DocIDs = body.DocIDs[:500]
}
links, err := h.store.GetOutLinksBatch(r.Context(), tenantID.String(), body.DocIDs)
if err != nil {
slog.Warn("vault.batch_links failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if links == nil {
links = []store.VaultLink{}
}
writeJSON(w, http.StatusOK, links)
}
// handleDeleteLink deletes a vault link.
func (h *VaultHandler) handleDeleteLink(w http.ResponseWriter, r *http.Request) {
tenantID := store.TenantIDFromContext(r.Context())
linkID := r.PathValue("linkID")
if err := h.store.DeleteLink(r.Context(), tenantID.String(), linkID); err != nil {
slog.Warn("vault.delete_link failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}