mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user