mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
feat(vault): chat_id isolation for isolated teams
- Add vault_documents.chat_id + composite index (migration 000056) - Filter vault_search by chat_id when team.workspace_scope=isolated - Stamp chat_id on AfterWrite/AfterWriteMedia for isolated teams - Deny cross-chat vault_read in isolated teams (M2 fix) - RunContext.TeamIsolated flag resolved once per run - Fallback WorkspaceChatID → ChatID in loop_context for entry points that don't set WorkspaceChatID explicitly (WS direct, HTTP, cron) Fixes cross-chat doc leak where agent in chat A could see vault docs from chat B within the same isolated team.
This commit is contained in:
1 parent
955b7a512f
commit
c7b2df9e34
22 files changed
+457
-68
No files matched your search
@@ -389,6 +389,7 @@ func processNormalMessage(
|
||||
ChannelType: resolveChannelType(deps.ChannelMgr, msg.Channel),
|
||||
ChatTitle: msg.Metadata[tools.MetaChatTitle],
|
||||
ChatID: msg.ChatID,
|
||||
WorkspaceChatID: msg.ChatID,
|
||||
PeerKind: peerKind,
|
||||
LocalKey: msg.Metadata["local_key"],
|
||||
UserID: userID,
|
||||
|
||||
@@ -119,8 +119,16 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
if req.WorkspaceChannel != "" {
|
||||
ctx = tools.WithWorkspaceChannel(ctx, req.WorkspaceChannel)
|
||||
}
|
||||
if req.WorkspaceChatID != "" {
|
||||
ctx = tools.WithWorkspaceChatID(ctx, req.WorkspaceChatID)
|
||||
// WorkspaceChatID drives vault chat_id isolation in isolated teams. Callers
|
||||
// that don't set it explicitly fall back to req.ChatID — the chat segment
|
||||
// used for workspace path layering — so the vault filter activates uniformly
|
||||
// across every RunRequest entry point (WS direct, HTTP, cron, subagent).
|
||||
effectiveWorkspaceChatID := req.WorkspaceChatID
|
||||
if effectiveWorkspaceChatID == "" {
|
||||
effectiveWorkspaceChatID = req.ChatID
|
||||
}
|
||||
if effectiveWorkspaceChatID != "" {
|
||||
ctx = tools.WithWorkspaceChatID(ctx, effectiveWorkspaceChatID)
|
||||
}
|
||||
if req.TeamTaskID != "" {
|
||||
ctx = tools.WithTeamTaskID(ctx, req.TeamTaskID)
|
||||
@@ -178,6 +186,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
}
|
||||
if req.TeamID != "" {
|
||||
ctx = tools.WithToolTeamID(ctx, req.TeamID)
|
||||
// Team root for dispatched tasks: resolve the UserChatLayer-stripped root
|
||||
// so the dispatched agent can still read peer-scoped files in the same team.
|
||||
if teamUUID, err := uuid.Parse(req.TeamID); err == nil && l.dataDir != "" {
|
||||
teamRoot := tools.ResolveWorkspace(l.dataDir,
|
||||
tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
|
||||
tools.TeamLayer(teamUUID),
|
||||
)
|
||||
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
|
||||
}
|
||||
}
|
||||
if req.LeaderAgentID != "" {
|
||||
ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID)
|
||||
@@ -186,6 +203,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
// Team workspace: auto-resolve for agents with team membership (not dispatched).
|
||||
// Lead agents default to team workspace; non-lead members keep own workspace.
|
||||
var resolvedTeamSettings json.RawMessage
|
||||
// Dispatched tasks already have TeamWorkspace set but still need team settings
|
||||
// for TeamIsolated flag. Fetch by explicit TeamID in that branch.
|
||||
if req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
|
||||
if teamUUID, err := uuid.Parse(req.TeamID); err == nil {
|
||||
if team, _ := l.teamStore.GetTeam(ctx, teamUUID); team != nil {
|
||||
resolvedTeamSettings = team.Settings
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
|
||||
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil {
|
||||
resolvedTeamSettings = team.Settings
|
||||
@@ -204,6 +230,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
slog.Warn("failed to create team workspace directory", "workspace", wsDir, "error", err)
|
||||
}
|
||||
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
|
||||
// Team root (no UserChatLayer): lets any team agent — leader or member —
|
||||
// read files produced by peers under different chat/user scopes within
|
||||
// the same team. Writes still default to wsDir above; team root only
|
||||
// widens the allowed-prefix set for path boundary checks.
|
||||
teamRoot := tools.ResolveWorkspace(l.dataDir,
|
||||
tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
|
||||
tools.TeamLayer(team.ID),
|
||||
)
|
||||
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
|
||||
// Leader keeps personal workspace (set at line 110-132) as default.
|
||||
// Team workspace accessible via ToolTeamWorkspaceFromCtx for delegation.
|
||||
if req.TeamID == "" {
|
||||
@@ -337,7 +372,8 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx),
|
||||
TeamID: tools.ToolTeamIDFromCtx(ctx),
|
||||
WorkspaceChannel: req.WorkspaceChannel,
|
||||
WorkspaceChatID: req.WorkspaceChatID,
|
||||
WorkspaceChatID: effectiveWorkspaceChatID,
|
||||
TeamIsolated: resolvedTeamSettings != nil && !tools.IsSharedWorkspace(resolvedTeamSettings),
|
||||
TeamTaskID: req.TeamTaskID,
|
||||
LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx),
|
||||
AgentToolKey: l.id,
|
||||
|
||||
@@ -276,13 +276,14 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
|
||||
}
|
||||
|
||||
result, err := loop.Run(runCtx, agent.RunRequest{
|
||||
SessionKey: sessionKey,
|
||||
Message: message,
|
||||
Media: mediaFiles,
|
||||
Channel: "ws",
|
||||
ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID())
|
||||
RunID: runID,
|
||||
UserID: userID,
|
||||
SessionKey: sessionKey,
|
||||
Message: message,
|
||||
Media: mediaFiles,
|
||||
Channel: "ws",
|
||||
ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID())
|
||||
WorkspaceChatID: userID, // mirror ChatID so vault chat_id isolation activates for WS direct flow
|
||||
RunID: runID,
|
||||
UserID: userID,
|
||||
Stream: params.Stream,
|
||||
InjectCh: injectCh,
|
||||
// Wire trace ID back to the active run so force-abort can mark the
|
||||
|
||||
@@ -112,10 +112,17 @@ func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocum
|
||||
}
|
||||
|
||||
var actualID uuid.UUID
|
||||
// Normalize chat_id: empty string → NULL.
|
||||
var chatID *string
|
||||
if doc.ChatID != nil && *doc.ChatID != "" {
|
||||
c := *doc.ChatID
|
||||
chatID = &c
|
||||
}
|
||||
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO vault_documents
|
||||
(id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, embedding, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14)
|
||||
(id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, title, doc_type, content_hash, summary, embedding, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
|
||||
ON CONFLICT (tenant_id, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(team_id, '00000000-0000-0000-0000-000000000000'::uuid), scope, path) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
doc_type = EXCLUDED.doc_type,
|
||||
@@ -123,10 +130,11 @@ func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocum
|
||||
summary = EXCLUDED.summary,
|
||||
embedding = COALESCE(EXCLUDED.embedding, vault_documents.embedding),
|
||||
metadata = EXCLUDED.metadata,
|
||||
chat_id = COALESCE(EXCLUDED.chat_id, vault_documents.chat_id),
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id`,
|
||||
id, tid, aid, teamID, doc.Scope, doc.CustomScope, doc.Path, doc.Title, doc.DocType,
|
||||
id, tid, aid, teamID, chatID, doc.Scope, doc.CustomScope, doc.Path, doc.Title, doc.DocType,
|
||||
doc.ContentHash, doc.Summary, embStr, meta, now,
|
||||
).Scan(&actualID)
|
||||
if err != nil {
|
||||
@@ -145,7 +153,7 @@ func (s *PGVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path
|
||||
return nil, fmt.Errorf("vault get document: tenant: %w", err)
|
||||
}
|
||||
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE tenant_id = $1 AND path = $2`
|
||||
args := []any{tid, path}
|
||||
p := 3
|
||||
@@ -177,7 +185,7 @@ func (s *PGVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path
|
||||
// Scan order MUST match SELECT order above: 15 columns including
|
||||
// path_basename (generated column added in migration 000047).
|
||||
err = s.db.QueryRowContext(ctx, q, args...).Scan(
|
||||
&row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.Scope, &row.CustomScope,
|
||||
&row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.ChatID, &row.Scope, &row.CustomScope,
|
||||
&row.Path, &row.PathBasename, &row.Title, &row.DocType, &row.ContentHash, &row.Summary,
|
||||
&row.MetaJSON, &row.CreatedAt, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
@@ -199,9 +207,9 @@ func (s *PGVaultStore) GetDocumentByID(ctx context.Context, tenantID, id string)
|
||||
}
|
||||
var row vaultDocRow
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE id = $1 AND tenant_id = $2`, uid, tid,
|
||||
).Scan(&row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.Scope, &row.CustomScope,
|
||||
).Scan(&row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.ChatID, &row.Scope, &row.CustomScope,
|
||||
&row.Path, &row.PathBasename, &row.Title, &row.DocType, &row.ContentHash, &row.Summary,
|
||||
&row.MetaJSON, &row.CreatedAt, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
@@ -227,7 +235,7 @@ func (s *PGVaultStore) GetDocumentsByIDs(ctx context.Context, tenantID string, d
|
||||
end := min(start+chunkSize, len(docIDs))
|
||||
var scanned []vaultDocRow
|
||||
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
||||
`SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
`SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE id = ANY($1) AND tenant_id = $2`,
|
||||
pqStringArray(docIDs[start:end]), tid); err != nil {
|
||||
return nil, err
|
||||
@@ -246,7 +254,7 @@ func (s *PGVaultStore) GetDocumentByBasename(ctx context.Context, tenantID, agen
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault get by basename: tenant: %w", err)
|
||||
}
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = $1 AND path_basename = lower($2)`
|
||||
args := []any{tid, basename}
|
||||
@@ -264,7 +272,7 @@ func (s *PGVaultStore) GetDocumentByBasename(ctx context.Context, tenantID, agen
|
||||
// Scan order MUST match SELECT order above: 15 columns including
|
||||
// path_basename (generated column added in migration 000047).
|
||||
err = s.db.QueryRowContext(ctx, q, args...).Scan(
|
||||
&row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.Scope, &row.CustomScope,
|
||||
&row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.ChatID, &row.Scope, &row.CustomScope,
|
||||
&row.Path, &row.PathBasename, &row.Title, &row.DocType, &row.ContentHash, &row.Summary,
|
||||
&row.MetaJSON, &row.CreatedAt, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
@@ -321,7 +329,7 @@ func (s *PGVaultStore) ListDocuments(ctx context.Context, tenantID, agentID stri
|
||||
return nil, fmt.Errorf("vault list documents: tenant: %w", err)
|
||||
}
|
||||
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE tenant_id = $1`
|
||||
args := []any{tid}
|
||||
p := 2
|
||||
@@ -445,6 +453,8 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions
|
||||
|
||||
// Build team filter for search sub-queries.
|
||||
tf := buildSearchTeamFilter(opts.TeamID, opts.TeamIDs)
|
||||
// Chat-scope filter (applies only when team is isolated + chat_id non-nil/non-empty).
|
||||
cf := buildSearchChatFilter(opts.ChatID, opts.TeamIsolated)
|
||||
|
||||
maxResults := opts.MaxResults
|
||||
if maxResults <= 0 {
|
||||
@@ -452,7 +462,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions
|
||||
}
|
||||
|
||||
// FTS search
|
||||
ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2)
|
||||
ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, tf, cf, opts.Scope, opts.DocTypes, maxResults*2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -463,7 +473,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions
|
||||
vecs, embErr := s.embProvider.Embed(ctx, []string{opts.Query})
|
||||
if embErr == nil && len(vecs) > 0 {
|
||||
var vecErr error
|
||||
vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2)
|
||||
vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, tf, cf, opts.Scope, opts.DocTypes, maxResults*2)
|
||||
if vecErr != nil {
|
||||
slog.Debug("vault.vector_search_fallback", "err", vecErr)
|
||||
vecResults = nil
|
||||
@@ -535,8 +545,32 @@ func (tf searchTeamFilter) append(q string, args []any, p int) (string, []any, i
|
||||
return q, args, p
|
||||
}
|
||||
|
||||
func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at,
|
||||
// searchChatFilter isolates vault search by chat_id when the calling team uses isolated workspace.
|
||||
// Predicate: (chat_id = $N OR chat_id IS NULL). NULL = team-wide doc (legacy or shared-mode write).
|
||||
type searchChatFilter struct {
|
||||
chatID string
|
||||
active bool
|
||||
}
|
||||
|
||||
func buildSearchChatFilter(chatID *string, teamIsolated bool) searchChatFilter {
|
||||
if !teamIsolated || chatID == nil || *chatID == "" {
|
||||
return searchChatFilter{}
|
||||
}
|
||||
return searchChatFilter{chatID: *chatID, active: true}
|
||||
}
|
||||
|
||||
func (cf searchChatFilter) append(q string, args []any, p int) (string, []any, int) {
|
||||
if !cf.active {
|
||||
return q, args, p
|
||||
}
|
||||
q += fmt.Sprintf(" AND (chat_id = $%d OR chat_id IS NULL)", p)
|
||||
args = append(args, cf.chatID)
|
||||
p++
|
||||
return q, args, p
|
||||
}
|
||||
|
||||
func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, cf searchChatFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at,
|
||||
ts_rank(tsv, plainto_tsquery('simple', $1)) AS score
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = $2 AND tsv @@ plainto_tsquery('simple', $1)`
|
||||
@@ -550,6 +584,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uui
|
||||
}
|
||||
|
||||
q, args, p = tf.append(q, args, p)
|
||||
q, args, p = cf.append(q, args, p)
|
||||
|
||||
if scope != "" {
|
||||
q += fmt.Sprintf(" AND scope = $%d", p)
|
||||
@@ -572,9 +607,9 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uui
|
||||
return vaultSearchRowsToResults(scanned, "vault"), nil
|
||||
}
|
||||
|
||||
func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, cf searchChatFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) {
|
||||
vecStr := vectorToString(embedding)
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at,
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at,
|
||||
1 - (embedding <=> $1) AS score
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = $2 AND embedding IS NOT NULL`
|
||||
@@ -588,6 +623,7 @@ func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, te
|
||||
}
|
||||
|
||||
q, args, p = tf.append(q, args, p)
|
||||
q, args, p = cf.append(q, args, p)
|
||||
|
||||
if scope != "" {
|
||||
q += fmt.Sprintf(" AND scope = $%d", p)
|
||||
|
||||
@@ -16,7 +16,7 @@ func (s *PGVaultStore) ListUnenrichedDocs(ctx context.Context, tenantID string,
|
||||
return nil, fmt.Errorf("vault list unenriched: tenant: %w", err)
|
||||
}
|
||||
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type,
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type,
|
||||
content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = $1 AND (summary IS NULL OR summary = '')
|
||||
@@ -102,7 +102,7 @@ func (s *PGVaultStore) FindSimilarDocs(ctx context.Context, tenantID, agentID, d
|
||||
return nil, nil // no embedding = no neighbors
|
||||
}
|
||||
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type,
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type,
|
||||
content_hash, summary, metadata, created_at, updated_at,
|
||||
1 - (embedding <=> $1::vector) AS score
|
||||
FROM vault_documents
|
||||
|
||||
@@ -16,6 +16,7 @@ type vaultDocRow struct {
|
||||
TenantID uuid.UUID `db:"tenant_id"`
|
||||
AgentID *uuid.UUID `db:"agent_id"`
|
||||
TeamID *uuid.UUID `db:"team_id"`
|
||||
ChatID *string `db:"chat_id"`
|
||||
Scope string `db:"scope"`
|
||||
CustomScope *string `db:"custom_scope"`
|
||||
Path string `db:"path"`
|
||||
@@ -53,6 +54,10 @@ func (r *vaultDocRow) toVaultDocument() store.VaultDocument {
|
||||
s := r.TeamID.String()
|
||||
doc.TeamID = &s
|
||||
}
|
||||
if r.ChatID != nil {
|
||||
s := *r.ChatID
|
||||
doc.ChatID = &s
|
||||
}
|
||||
if len(r.MetaJSON) > 0 {
|
||||
json.Unmarshal(r.MetaJSON, &doc.Metadata) //nolint:errcheck
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (s *PGVaultStore) BatchFindByDelegationIDs(
|
||||
q := `
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
vd.id, vd.tenant_id, vd.agent_id, vd.team_id, vd.scope, vd.custom_scope,
|
||||
vd.id, vd.tenant_id, vd.agent_id, vd.team_id, vd.chat_id, vd.scope, vd.custom_scope,
|
||||
vd.path, vd.path_basename, vd.title, vd.doc_type, vd.content_hash,
|
||||
vd.summary, vd.metadata, vd.created_at, vd.updated_at,
|
||||
vd.metadata->>'delegation_id' AS deleg_id,
|
||||
@@ -90,7 +90,7 @@ WITH ranked AS (
|
||||
args = append(args, pqStringArray(excludeUUIDs))
|
||||
}
|
||||
q += `)
|
||||
SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename,
|
||||
SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename,
|
||||
title, doc_type, content_hash, summary, metadata, created_at, updated_at, deleg_id
|
||||
FROM ranked
|
||||
WHERE rn <= $` + fmt.Sprintf("%d", len(args)+1) + `
|
||||
@@ -109,6 +109,7 @@ ORDER BY deleg_id, created_at DESC
|
||||
var (
|
||||
id, tenantIDVal uuid.UUID
|
||||
agentID, teamID *uuid.UUID
|
||||
chatID *string
|
||||
customScope *string
|
||||
metaJSON []byte
|
||||
delegID string
|
||||
@@ -118,12 +119,16 @@ ORDER BY deleg_id, created_at DESC
|
||||
)
|
||||
doc := store.VaultDocument{}
|
||||
if err := rows.Scan(
|
||||
&id, &tenantIDVal, &agentID, &teamID, &scope, &customScope,
|
||||
&id, &tenantIDVal, &agentID, &teamID, &chatID, &scope, &customScope,
|
||||
&path, &pathBase, &title, &docTyp, &contentHash,
|
||||
&summary, &metaJSON, &doc.CreatedAt, &doc.UpdatedAt, &delegID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if chatID != nil {
|
||||
v := *chatID
|
||||
doc.ChatID = &v
|
||||
}
|
||||
doc.ID = id.String()
|
||||
doc.TenantID = tenantIDVal.String()
|
||||
if agentID != nil {
|
||||
|
||||
@@ -52,6 +52,7 @@ type RunContext struct {
|
||||
TeamID string
|
||||
WorkspaceChannel string
|
||||
WorkspaceChatID string
|
||||
TeamIsolated bool // true when team.workspace_scope != "shared" — drives chat_id filtering in vault search
|
||||
TeamTaskID string
|
||||
DelegationID string // delegation identifier for vault auto-linking (empty when not in delegation)
|
||||
LeaderAgentID string // leader's agent UUID for member memory read fallback
|
||||
|
||||
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 24
|
||||
const SchemaVersion = 25
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -496,6 +496,11 @@ CREATE TRIGGER IF NOT EXISTS trg_vault_docs_scope_consistency_upd
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'vault_documents_scope_consistency violation');
|
||||
END;`,
|
||||
|
||||
// Version 24 → 25: add chat_id column + composite index (mirrors PG migration 000056).
|
||||
// SQLite lacks regex by default — skip backfill (desktop is single-user; cross-chat risk minimal).
|
||||
24: `ALTER TABLE vault_documents ADD COLUMN chat_id TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_team_chat ON vault_documents(team_id, chat_id) WHERE team_id IS NOT NULL;`,
|
||||
}
|
||||
|
||||
// addHooksTables is the SQLite incremental migration for schema v19 → v20.
|
||||
|
||||
@@ -1521,6 +1521,7 @@ CREATE TABLE IF NOT EXISTS vault_documents (
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||||
team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
|
||||
chat_id TEXT, -- NULL = team-wide (shared / legacy); non-NULL = scoped to specific chat for isolated teams
|
||||
scope TEXT NOT NULL DEFAULT 'personal',
|
||||
custom_scope TEXT,
|
||||
path TEXT NOT NULL,
|
||||
@@ -1547,6 +1548,7 @@ CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_i
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_hash ON vault_documents(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_team ON vault_documents(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_team_chat ON vault_documents(team_id, chat_id) WHERE team_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_basename ON vault_documents(tenant_id, path_basename);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_path_prefix ON vault_documents(tenant_id, path);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_delegation
|
||||
|
||||
@@ -330,6 +330,13 @@ func openTestDBAtVersion(t *testing.T, targetVersion int) *sql.DB {
|
||||
db.Exec(`DROP TABLE episodic_summaries_old`)
|
||||
}
|
||||
|
||||
if targetVersion < 25 {
|
||||
// Migration 24→25 adds vault_documents.chat_id + idx_vault_docs_team_chat.
|
||||
// Drop both so the migration's ALTER TABLE / CREATE INDEX succeed.
|
||||
db.Exec(`DROP INDEX IF EXISTS idx_vault_docs_team_chat`)
|
||||
db.Exec(`ALTER TABLE vault_documents DROP COLUMN chat_id`)
|
||||
}
|
||||
|
||||
// Set version back to target.
|
||||
db.Exec("UPDATE schema_version SET version = ?", targetVersion)
|
||||
return db
|
||||
|
||||
@@ -63,6 +63,11 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
|
||||
if doc.AgentID != nil && *doc.AgentID != "" {
|
||||
agentIDVal = *doc.AgentID
|
||||
}
|
||||
// Normalize chat_id: empty string → NULL (treat as team-wide).
|
||||
var chatIDVal any
|
||||
if doc.ChatID != nil && *doc.ChatID != "" {
|
||||
chatIDVal = *doc.ChatID
|
||||
}
|
||||
// SQLite has no GENERATED column equivalent via modernc driver; compute
|
||||
// the basename app-side. PG auto-populates via GENERATED so this is a
|
||||
// no-op on PG callers that share the struct.
|
||||
@@ -71,8 +76,8 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
|
||||
}
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO vault_documents
|
||||
(id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (tenant_id, COALESCE(agent_id,''), COALESCE(team_id,''), scope, path) DO UPDATE SET
|
||||
path_basename = excluded.path_basename,
|
||||
title = excluded.title,
|
||||
@@ -80,10 +85,11 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
|
||||
content_hash = excluded.content_hash,
|
||||
summary = excluded.summary,
|
||||
metadata = excluded.metadata,
|
||||
chat_id = COALESCE(excluded.chat_id, vault_documents.chat_id),
|
||||
tenant_id = excluded.tenant_id,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id`,
|
||||
id, doc.TenantID, agentIDVal, doc.TeamID, doc.Scope, doc.CustomScope,
|
||||
id, doc.TenantID, agentIDVal, doc.TeamID, chatIDVal, doc.Scope, doc.CustomScope,
|
||||
doc.Path, doc.PathBasename, doc.Title, doc.DocType, doc.ContentHash, doc.Summary, string(meta), now, now,
|
||||
).Scan(&doc.ID)
|
||||
if err != nil {
|
||||
@@ -96,7 +102,7 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD
|
||||
// Empty agentID means no agent filter.
|
||||
// Team scoping via RunContext: present+TeamID → filter; present+empty → personal; nil → any match.
|
||||
func (s *SQLiteVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path string) (*store.VaultDocument, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE tenant_id = ? AND path = ?`
|
||||
args := []any{tenantID, path}
|
||||
|
||||
@@ -121,7 +127,7 @@ func (s *SQLiteVaultStore) GetDocument(ctx context.Context, tenantID, agentID, p
|
||||
// GetDocumentByID retrieves a vault document by ID with tenant isolation.
|
||||
func (s *SQLiteVaultStore) GetDocumentByID(ctx context.Context, tenantID, id string) (*store.VaultDocument, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE id = ? AND tenant_id = ?`, id, tenantID)
|
||||
return scanVaultDoc(row)
|
||||
}
|
||||
@@ -143,7 +149,7 @@ func (s *SQLiteVaultStore) GetDocumentsByIDs(ctx context.Context, tenantID strin
|
||||
args[i] = id
|
||||
}
|
||||
args = append(args, tenantID)
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE id IN (` + strings.Join(ph, ",") + `) AND tenant_id = ?`
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
@@ -167,7 +173,7 @@ func (s *SQLiteVaultStore) GetDocumentsByIDs(ctx context.Context, tenantID strin
|
||||
|
||||
// GetDocumentByBasename finds a document by path basename (case-insensitive).
|
||||
func (s *SQLiteVaultStore) GetDocumentByBasename(ctx context.Context, tenantID, agentID, basename string) (*store.VaultDocument, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = ? AND lower(replace(path, rtrim(path, replace(path, '/', '')), '')) = lower(?)`
|
||||
args := []any{tenantID, basename}
|
||||
@@ -207,7 +213,7 @@ func (s *SQLiteVaultStore) DeleteDocument(ctx context.Context, tenantID, agentID
|
||||
|
||||
// ListDocuments returns vault documents with optional scope/type filters.
|
||||
func (s *SQLiteVaultStore) ListDocuments(ctx context.Context, tenantID, agentID string, opts store.VaultListOptions) ([]store.VaultDocument, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents WHERE tenant_id = ?`
|
||||
args := []any{tenantID}
|
||||
|
||||
@@ -298,7 +304,7 @@ func (s *SQLiteVaultStore) UpdateHash(ctx context.Context, tenantID, id, newHash
|
||||
// ListUnenrichedDocs returns documents with empty summary for re-enrichment.
|
||||
// limit=0 means no limit.
|
||||
func (s *SQLiteVaultStore) ListUnenrichedDocs(ctx context.Context, tenantID string, limit int) ([]store.VaultDocument, error) {
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = ? AND (summary IS NULL OR summary = '')
|
||||
ORDER BY created_at ASC`
|
||||
@@ -355,7 +361,7 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt
|
||||
maxResults = 10
|
||||
}
|
||||
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at
|
||||
FROM vault_documents
|
||||
WHERE tenant_id = ?
|
||||
AND (title LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\')`
|
||||
@@ -368,6 +374,11 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt
|
||||
|
||||
q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs)
|
||||
|
||||
if opts.TeamIsolated && opts.ChatID != nil && *opts.ChatID != "" {
|
||||
q += " AND (chat_id = ? OR chat_id IS NULL)"
|
||||
args = append(args, *opts.ChatID)
|
||||
}
|
||||
|
||||
q += " ORDER BY updated_at DESC LIMIT ?"
|
||||
args = append(args, maxResults*2)
|
||||
|
||||
@@ -524,14 +535,15 @@ func sqliteAppendTreeFilters(q string, args []any, opts store.VaultTreeOptions)
|
||||
func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) {
|
||||
var doc store.VaultDocument
|
||||
var meta []byte
|
||||
var agentID *string
|
||||
var agentID, chatID *string
|
||||
ca, ua := &sqliteTime{}, &sqliteTime{}
|
||||
err := row.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
|
||||
err := row.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &chatID, &doc.Scope, &doc.CustomScope,
|
||||
&doc.Path, &doc.PathBasename, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.AgentID = agentID
|
||||
doc.ChatID = chatID
|
||||
doc.CreatedAt = ca.Time
|
||||
doc.UpdatedAt = ua.Time
|
||||
if len(meta) > 2 {
|
||||
@@ -543,14 +555,15 @@ func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) {
|
||||
func scanVaultDocRow(rows *sql.Rows) (*store.VaultDocument, error) {
|
||||
var doc store.VaultDocument
|
||||
var meta []byte
|
||||
var agentID *string
|
||||
var agentID, chatID *string
|
||||
ca, ua := &sqliteTime{}, &sqliteTime{}
|
||||
err := rows.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &doc.Scope, &doc.CustomScope,
|
||||
err := rows.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &chatID, &doc.Scope, &doc.CustomScope,
|
||||
&doc.Path, &doc.PathBasename, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.AgentID = agentID
|
||||
doc.ChatID = chatID
|
||||
doc.CreatedAt = ca.Time
|
||||
doc.UpdatedAt = ua.Time
|
||||
if len(meta) > 2 {
|
||||
|
||||
@@ -11,6 +11,7 @@ type VaultDocument struct {
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
AgentID *string `json:"agent_id,omitempty" db:"agent_id"`
|
||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||
ChatID *string `json:"chat_id,omitempty" db:"chat_id"` // nil = team-wide (shared / legacy); non-nil = scoped to specific chat in isolated teams
|
||||
Scope string `json:"scope" db:"scope"` // personal, team, shared
|
||||
CustomScope *string `json:"custom_scope,omitempty" db:"custom_scope"`
|
||||
Path string `json:"path" db:"path"` // workspace-relative path
|
||||
@@ -58,6 +59,8 @@ type VaultSearchOptions struct {
|
||||
TenantID string
|
||||
TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team
|
||||
TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view)
|
||||
ChatID *string // isolated-team scope: when non-nil + TeamIsolated, filter (chat_id = ChatID OR chat_id IS NULL)
|
||||
TeamIsolated bool // true = apply ChatID filter; false = shared/no-team mode (ignore ChatID)
|
||||
Scope string // empty = all scopes
|
||||
DocTypes []string // empty = all types
|
||||
MaxResults int // default 10
|
||||
|
||||
@@ -36,6 +36,21 @@ func inferScopeFromContext(ctx context.Context) (scope string, teamID *string, a
|
||||
return "personal", nil, true
|
||||
}
|
||||
|
||||
// inferChatIDFromContext returns the chat_id to stamp on a vault doc.
|
||||
// Non-nil only when team uses isolated workspace scope AND WorkspaceChatID is set.
|
||||
// Shared/personal scope → nil (team-wide, matches any chat in search).
|
||||
func inferChatIDFromContext(ctx context.Context) *string {
|
||||
rc := store.RunContextFromCtx(ctx)
|
||||
if rc == nil || rc.TeamID == "" || !rc.TeamIsolated {
|
||||
return nil
|
||||
}
|
||||
chatID := WorkspaceChatIDFromCtx(ctx)
|
||||
if chatID == "" {
|
||||
return nil
|
||||
}
|
||||
return &chatID
|
||||
}
|
||||
|
||||
// AfterWrite registers or updates a vault document after a file write.
|
||||
// Non-blocking: errors logged but not propagated.
|
||||
func (v *VaultInterceptor) AfterWrite(ctx context.Context, resolvedPath, content string) {
|
||||
@@ -73,6 +88,7 @@ func (v *VaultInterceptor) AfterWrite(ctx context.Context, resolvedPath, content
|
||||
TenantID: tenantID,
|
||||
AgentID: agentIDPtr,
|
||||
TeamID: teamID,
|
||||
ChatID: inferChatIDFromContext(ctx),
|
||||
Scope: scope,
|
||||
Path: relPath,
|
||||
Title: title,
|
||||
@@ -164,6 +180,7 @@ func (v *VaultInterceptor) AfterWriteMedia(ctx context.Context, resolvedPath, su
|
||||
TenantID: tenantID,
|
||||
AgentID: agentIDPtr,
|
||||
TeamID: teamID,
|
||||
ChatID: inferChatIDFromContext(ctx),
|
||||
Scope: scope,
|
||||
Path: relPath,
|
||||
Title: title,
|
||||
|
||||
@@ -326,7 +326,19 @@ func (t *VaultReadTool) allowed(ctx context.Context, doc *store.VaultDocument) b
|
||||
if rc == nil || rc.TeamID == "" {
|
||||
return false
|
||||
}
|
||||
return rc.TeamID == *doc.TeamID
|
||||
if rc.TeamID != *doc.TeamID {
|
||||
return false
|
||||
}
|
||||
// Chat scope: isolated teams restrict cross-chat reads. Docs with
|
||||
// chat_id = NULL are team-wide (legacy or shared-mode writes); docs
|
||||
// with chat_id set must match caller's WorkspaceChatID.
|
||||
if rc.TeamIsolated && doc.ChatID != nil && *doc.ChatID != "" {
|
||||
callerChat := WorkspaceChatIDFromCtx(ctx)
|
||||
if callerChat == "" || callerChat != *doc.ChatID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -244,6 +244,93 @@ func TestVaultRead_TeamScope_NoContext_Deny(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5a. isolated team, cross-chat doc → deny. ---
|
||||
func TestVaultRead_TeamScope_IsolatedCrossChat_Deny(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
docID := uuid.New()
|
||||
teamID := uuid.New().String()
|
||||
tid := teamID
|
||||
chatA := "chatA"
|
||||
doc := &store.VaultDocument{
|
||||
ID: docID.String(), TenantID: tenantID.String(),
|
||||
TeamID: &tid, ChatID: &chatA,
|
||||
Scope: "team", Path: "team/doc.md",
|
||||
Title: "Team Doc", DocType: "note",
|
||||
}
|
||||
tool, ws := newVaultReadTestTool(t, doc)
|
||||
writeFile(t, ws, "team/doc.md", "team body")
|
||||
|
||||
// Caller bound to chatB in isolated team → deny.
|
||||
ctx := store.WithRunContext(
|
||||
makeCtx(tenantID, agentID),
|
||||
&store.RunContext{
|
||||
TenantID: tenantID, AgentID: agentID,
|
||||
TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatB",
|
||||
})
|
||||
res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()})
|
||||
if !res.IsError || !strings.Contains(res.ForLLM, "not accessible") {
|
||||
t.Fatalf("expected cross-chat deny, got: %s", res.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5b. isolated team, same-chat doc → allow. ---
|
||||
func TestVaultRead_TeamScope_IsolatedSameChat_Allow(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
docID := uuid.New()
|
||||
teamID := uuid.New().String()
|
||||
tid := teamID
|
||||
chatA := "chatA"
|
||||
doc := &store.VaultDocument{
|
||||
ID: docID.String(), TenantID: tenantID.String(),
|
||||
TeamID: &tid, ChatID: &chatA,
|
||||
Scope: "team", Path: "team/doc.md",
|
||||
Title: "Team Doc", DocType: "note",
|
||||
}
|
||||
tool, ws := newVaultReadTestTool(t, doc)
|
||||
writeFile(t, ws, "team/doc.md", "team body")
|
||||
|
||||
ctx := store.WithRunContext(
|
||||
makeCtx(tenantID, agentID),
|
||||
&store.RunContext{
|
||||
TenantID: tenantID, AgentID: agentID,
|
||||
TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatA",
|
||||
})
|
||||
res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()})
|
||||
if res.IsError {
|
||||
t.Fatalf("expected allow for same-chat, got error: %s", res.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5c. isolated team, team-wide doc (chat_id NULL) → allow regardless of chat. ---
|
||||
func TestVaultRead_TeamScope_IsolatedTeamWide_Allow(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
docID := uuid.New()
|
||||
teamID := uuid.New().String()
|
||||
tid := teamID
|
||||
doc := &store.VaultDocument{
|
||||
ID: docID.String(), TenantID: tenantID.String(),
|
||||
TeamID: &tid, ChatID: nil, // team-wide
|
||||
Scope: "team", Path: "team/doc.md",
|
||||
Title: "Team Doc", DocType: "note",
|
||||
}
|
||||
tool, ws := newVaultReadTestTool(t, doc)
|
||||
writeFile(t, ws, "team/doc.md", "team body")
|
||||
|
||||
ctx := store.WithRunContext(
|
||||
makeCtx(tenantID, agentID),
|
||||
&store.RunContext{
|
||||
TenantID: tenantID, AgentID: agentID,
|
||||
TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatZ",
|
||||
})
|
||||
res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()})
|
||||
if res.IsError {
|
||||
t.Fatalf("team-wide doc should be accessible in isolated team, got: %s", res.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 6. cross-tenant (different tenant in ctx) → not-found. ---
|
||||
func TestVaultRead_CrossTenant_NotFound(t *testing.T) {
|
||||
tenantA := uuid.New()
|
||||
|
||||
@@ -92,9 +92,15 @@ func (t *VaultSearchTool) Execute(ctx context.Context, args map[string]any) *Res
|
||||
UserID: userID,
|
||||
TenantID: tenantID.String(),
|
||||
}
|
||||
// Team context from RunContext — cannot be spoofed via tool args.
|
||||
// Team + chat context from RunContext — cannot be spoofed via tool args.
|
||||
if rc := store.RunContextFromCtx(ctx); rc != nil && rc.TeamID != "" {
|
||||
opts.TeamID = &rc.TeamID
|
||||
if rc.TeamIsolated {
|
||||
opts.TeamIsolated = true
|
||||
if chatID := WorkspaceChatIDFromCtx(ctx); chatID != "" {
|
||||
opts.ChatID = &chatID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if scope, ok := args["scope"].(string); ok && scope != "" {
|
||||
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 55
|
||||
const RequiredSchemaVersion uint = 56
|
||||
+22
-18
@@ -35,16 +35,18 @@ func DefaultSearchWeights() SearchWeights {
|
||||
|
||||
// UnifiedSearchOptions configures a cross-store search query.
|
||||
type UnifiedSearchOptions struct {
|
||||
Query string
|
||||
AgentID string
|
||||
UserID string
|
||||
TenantID string
|
||||
TeamID *string // nil = no filter (owner), ptr-to-empty = personal, ptr-to-uuid = team
|
||||
Scope string
|
||||
DocTypes []string
|
||||
MaxResults int
|
||||
MinScore float64
|
||||
Weights SearchWeights
|
||||
Query string
|
||||
AgentID string
|
||||
UserID string
|
||||
TenantID string
|
||||
TeamID *string // nil = no filter (owner), ptr-to-empty = personal, ptr-to-uuid = team
|
||||
ChatID *string // isolated-team scope: filter docs to (chat_id = ChatID OR chat_id IS NULL)
|
||||
TeamIsolated bool // true = apply ChatID filter; false = shared/personal (ignore ChatID)
|
||||
Scope string
|
||||
DocTypes []string
|
||||
MaxResults int
|
||||
MinScore float64
|
||||
Weights SearchWeights
|
||||
}
|
||||
|
||||
// UnifiedSearchResult is a normalized result from any search source.
|
||||
@@ -91,14 +93,16 @@ func (s *VaultSearchService) Search(ctx context.Context, opts UnifiedSearchOptio
|
||||
if s.vaultStore != nil {
|
||||
wg.Go(func() {
|
||||
results, err := s.vaultStore.Search(ctx, store.VaultSearchOptions{
|
||||
Query: opts.Query,
|
||||
AgentID: opts.AgentID,
|
||||
TenantID: opts.TenantID,
|
||||
TeamID: opts.TeamID,
|
||||
Scope: opts.Scope,
|
||||
DocTypes: opts.DocTypes,
|
||||
MaxResults: opts.MaxResults * 2,
|
||||
MinScore: opts.MinScore,
|
||||
Query: opts.Query,
|
||||
AgentID: opts.AgentID,
|
||||
TenantID: opts.TenantID,
|
||||
TeamID: opts.TeamID,
|
||||
ChatID: opts.ChatID,
|
||||
TeamIsolated: opts.TeamIsolated,
|
||||
Scope: opts.Scope,
|
||||
DocTypes: opts.DocTypes,
|
||||
MaxResults: opts.MaxResults * 2,
|
||||
MinScore: opts.MinScore,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_vault_docs_team_chat;
|
||||
ALTER TABLE vault_documents DROP COLUMN IF EXISTS chat_id;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Add chat_id to vault_documents for cross-chat isolation within isolated teams.
|
||||
-- NULL = team-wide doc (shared mode or legacy); non-NULL = scoped to specific chat.
|
||||
ALTER TABLE vault_documents ADD COLUMN IF NOT EXISTS chat_id TEXT;
|
||||
|
||||
-- Composite index for team + chat filtering (primary query pattern for isolated teams).
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_docs_team_chat
|
||||
ON vault_documents(team_id, chat_id)
|
||||
WHERE team_id IS NOT NULL;
|
||||
|
||||
-- Backfill chat_id for isolated teams. Two path layouts exist:
|
||||
-- master tenant: teams/<team_uuid>/<chat>/...
|
||||
-- non-master tenant: tenants/<slug>/teams/<team_uuid>/<chat>/...
|
||||
-- Chat segments starting with '.' (e.g. '.goclaw') are config dirs, not real chats — skip.
|
||||
UPDATE vault_documents vd
|
||||
SET chat_id = (regexp_match(vd.path, '^(?:tenants/[^/]+/)?teams/[^/]+/([^/]+)/'))[1]
|
||||
FROM agent_teams t
|
||||
WHERE vd.team_id = t.id
|
||||
AND (t.settings->>'workspace_scope' IS NULL OR t.settings->>'workspace_scope' != 'shared')
|
||||
AND vd.path ~ '^(?:tenants/[^/]+/)?teams/[^/]+/[^.][^/]*/';
|
||||
@@ -0,0 +1,127 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// vaultSearchPathsWithChat runs a search bound to a specific chat scope and
|
||||
// returns sorted result paths. Used by cross-chat isolation assertions.
|
||||
func vaultSearchPathsWithChat(t *testing.T, vs store.VaultStore, ctx context.Context, tenantID, agentID string, teamID *string, chatID string, isolated bool) []string {
|
||||
t.Helper()
|
||||
var chatPtr *string
|
||||
if chatID != "" {
|
||||
chatPtr = &chatID
|
||||
}
|
||||
results, err := vs.Search(ctx, store.VaultSearchOptions{
|
||||
TenantID: tenantID,
|
||||
AgentID: agentID,
|
||||
TeamID: teamID,
|
||||
ChatID: chatPtr,
|
||||
TeamIsolated: isolated,
|
||||
Query: "note",
|
||||
MaxResults: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Search(chat=%s isolated=%v): %v", chatID, isolated, err)
|
||||
}
|
||||
paths := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
paths = append(paths, r.Document.Path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
// upsertChatDoc inserts a team-scoped vault doc tagged with a chat_id.
|
||||
func upsertChatDoc(t *testing.T, vs store.VaultStore, ctx context.Context, tenantID, teamID uuid.UUID, chatID, path string) {
|
||||
t.Helper()
|
||||
var chatPtr *string
|
||||
if chatID != "" {
|
||||
cid := chatID
|
||||
chatPtr = &cid
|
||||
}
|
||||
teamStr := teamID.String()
|
||||
doc := &store.VaultDocument{
|
||||
TenantID: tenantID.String(),
|
||||
TeamID: &teamStr,
|
||||
ChatID: chatPtr,
|
||||
Scope: "team",
|
||||
Path: path,
|
||||
Title: "note-" + path,
|
||||
DocType: "note",
|
||||
ContentHash: "h-" + path,
|
||||
Summary: "note summary for " + path,
|
||||
}
|
||||
if err := vs.UpsertDocument(ctx, doc); err != nil {
|
||||
t.Fatalf("UpsertDocument(%s chat=%s): %v", path, chatID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVaultChatIDIsolation_Isolated verifies that isolated-team vault search
|
||||
// excludes cross-chat docs while still returning team-wide (chat_id IS NULL)
|
||||
// docs and same-chat docs.
|
||||
func TestVaultChatIDIsolation_Isolated(t *testing.T) {
|
||||
db := testDB(t)
|
||||
vs := newVaultStore(db)
|
||||
tenantA, _, agentA, _ := seedTwoTenants(t, db)
|
||||
ctx := tenantCtx(tenantA)
|
||||
teamID, _ := seedTeam(t, db, tenantA, agentA)
|
||||
|
||||
// Three docs for the same team: chatA-only, chatB-only, team-wide (NULL).
|
||||
upsertChatDoc(t, vs, ctx, tenantA, teamID, "chatA", "teams/t/chatA/noteA.md")
|
||||
upsertChatDoc(t, vs, ctx, tenantA, teamID, "chatB", "teams/t/chatB/noteB.md")
|
||||
upsertChatDoc(t, vs, ctx, tenantA, teamID, "", "teams/t/shared-note.md")
|
||||
|
||||
teamStr := teamID.String()
|
||||
|
||||
// Agent in chatA with isolated scope: sees chatA + team-wide, NOT chatB.
|
||||
gotA := vaultSearchPathsWithChat(t, vs, ctx, tenantA.String(), agentA.String(), &teamStr, "chatA", true)
|
||||
wantA := []string{"teams/t/chatA/noteA.md", "teams/t/shared-note.md"}
|
||||
assertEqualPaths(t, "isolated chatA", wantA, gotA)
|
||||
|
||||
// Agent in chatB with isolated scope: sees chatB + team-wide, NOT chatA.
|
||||
gotB := vaultSearchPathsWithChat(t, vs, ctx, tenantA.String(), agentA.String(), &teamStr, "chatB", true)
|
||||
wantB := []string{"teams/t/chatB/noteB.md", "teams/t/shared-note.md"}
|
||||
assertEqualPaths(t, "isolated chatB", wantB, gotB)
|
||||
}
|
||||
|
||||
// TestVaultChatIDIsolation_SharedUnfiltered verifies that when TeamIsolated
|
||||
// is false (shared workspace) the chat_id filter is skipped entirely — agents
|
||||
// see every team doc regardless of origin chat. This is the pre-chat_id
|
||||
// behavior and must not regress for shared teams.
|
||||
func TestVaultChatIDIsolation_SharedUnfiltered(t *testing.T) {
|
||||
db := testDB(t)
|
||||
vs := newVaultStore(db)
|
||||
tenantA, _, agentA, _ := seedTwoTenants(t, db)
|
||||
ctx := tenantCtx(tenantA)
|
||||
teamID, _ := seedTeam(t, db, tenantA, agentA)
|
||||
|
||||
upsertChatDoc(t, vs, ctx, tenantA, teamID, "chatA", "teams/t/chatA/noteA.md")
|
||||
upsertChatDoc(t, vs, ctx, tenantA, teamID, "chatB", "teams/t/chatB/noteB.md")
|
||||
upsertChatDoc(t, vs, ctx, tenantA, teamID, "", "teams/t/shared-note.md")
|
||||
|
||||
teamStr := teamID.String()
|
||||
got := vaultSearchPathsWithChat(t, vs, ctx, tenantA.String(), agentA.String(), &teamStr, "chatA", false)
|
||||
want := []string{"teams/t/chatA/noteA.md", "teams/t/chatB/noteB.md", "teams/t/shared-note.md"}
|
||||
assertEqualPaths(t, "shared mode (no filter)", want, got)
|
||||
}
|
||||
|
||||
func assertEqualPaths(t *testing.T, label string, want, got []string) {
|
||||
t.Helper()
|
||||
if len(want) != len(got) {
|
||||
t.Fatalf("%s: paths mismatch\n want: %v\n got: %v", label, want, got)
|
||||
}
|
||||
for i := range want {
|
||||
if want[i] != got[i] {
|
||||
t.Fatalf("%s: paths mismatch\n want: %v\n got: %v", label, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user