mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
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.
This commit is contained in:
1 parent
c7b2df9e34
commit
9625a20e8b
4 files changed
+82
-20
No files matched your search
@@ -30,6 +30,7 @@ func (h *VaultHandler) doSearch(w http.ResponseWriter, r *http.Request, agentID
|
||||
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
|
||||
@@ -59,6 +60,12 @@ func (h *VaultHandler) doSearch(w http.ResponseWriter, r *http.Request, agentID
|
||||
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
|
||||
|
||||
+26
-18
@@ -55,7 +55,7 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
agentID, teamID, scope, strippedPath := inferOwnerFromPath(entry.RelPath, params.AgentMap, params.TeamSet)
|
||||
agentID, teamID, chatID, scope, strippedPath := inferOwnerFromPath(entry.RelPath, params.AgentMap, params.TeamSet)
|
||||
if scope == "" {
|
||||
// Unknown agent key or invalid team UUID — skip.
|
||||
result.Skipped++
|
||||
@@ -94,6 +94,7 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
|
||||
TenantID: params.TenantID,
|
||||
AgentID: agentID,
|
||||
TeamID: teamID,
|
||||
ChatID: chatID,
|
||||
Scope: scope,
|
||||
Path: relPath,
|
||||
Title: InferTitle(relPath),
|
||||
@@ -148,33 +149,40 @@ func RescanWorkspace(ctx context.Context, params RescanParams, vs store.VaultSto
|
||||
}
|
||||
|
||||
// inferOwnerFromPath parses a tenant-relative path to determine ownership.
|
||||
// Returns: agentID (*string), teamID (*string), scope (string), strippedPath (string).
|
||||
// Returns: agentID (*string), teamID (*string), chatID (*string), scope, strippedPath.
|
||||
//
|
||||
// Path patterns (checked in order):
|
||||
//
|
||||
// teams/{team_uuid}/rest/of/path → teamID=uuid, scope="team", path=full relPath
|
||||
// agents/{agent_key}/rest/of/path → agentID=lookup(key), scope="personal", path=full relPath (legacy)
|
||||
// {agent_key}/rest/of/path → agentID=lookup(key), scope="personal", path=full relPath (workspace layout)
|
||||
// anything/else → scope="shared", path unchanged
|
||||
// teams/{team_uuid}/{chat}/... → teamID=uuid, chatID=chat, scope="team"
|
||||
// teams/{team_uuid}/file.md → teamID=uuid, chatID=nil (team-wide), scope="team"
|
||||
// agents/{agent_key}/... → agentID=lookup(key), scope="personal" (legacy prefix)
|
||||
// {agent_key}/... → agentID=lookup(key), scope="personal" (workspace layout)
|
||||
//
|
||||
// The full relPath is always preserved in strippedPath for DB storage so enrichment
|
||||
// workers can locate files via filepath.Join(workspace, path).
|
||||
// Chat segments starting with "." (e.g. ".goclaw") are config dirs, not real chats — chatID stays nil.
|
||||
// The full relPath is preserved in strippedPath for DB storage so enrichment workers
|
||||
// can locate files via filepath.Join(workspace, path).
|
||||
// Returns scope="" to signal the file should be skipped (unknown agent or invalid team).
|
||||
func inferOwnerFromPath(relPath string, agentMap map[string]string, teamSet map[string]bool) (agentID *string, teamID *string, scope string, strippedPath string) {
|
||||
// Team paths: teams/{uuid}/...
|
||||
func inferOwnerFromPath(relPath string, agentMap map[string]string, teamSet map[string]bool) (agentID *string, teamID *string, chatID *string, scope string, strippedPath string) {
|
||||
// Team paths: teams/{uuid}/[chat/]...
|
||||
if strings.HasPrefix(relPath, "teams/") {
|
||||
rest := relPath[len("teams/"):]
|
||||
id, remainder, hasSlash := strings.Cut(rest, "/")
|
||||
if !hasSlash || id == "" || strings.Contains(remainder, "..") {
|
||||
return nil, nil, "", relPath
|
||||
return nil, nil, nil, "", relPath
|
||||
}
|
||||
if _, parseErr := uuid.Parse(id); parseErr != nil {
|
||||
return nil, nil, "", relPath
|
||||
return nil, nil, nil, "", relPath
|
||||
}
|
||||
if !teamSet[id] {
|
||||
return nil, nil, "", relPath
|
||||
return nil, nil, nil, "", relPath
|
||||
}
|
||||
return nil, &id, "team", relPath
|
||||
// Extract chat segment (second path component after team uuid) if present
|
||||
// and not a config/hidden dir. Paths without a chat segment stay team-wide.
|
||||
if chatSeg, _, hasChat := strings.Cut(remainder, "/"); hasChat && chatSeg != "" && !strings.HasPrefix(chatSeg, ".") {
|
||||
cid := chatSeg
|
||||
return nil, &id, &cid, "team", relPath
|
||||
}
|
||||
return nil, &id, nil, "team", relPath
|
||||
}
|
||||
|
||||
// Agent paths: agents/{key}/... (legacy prefix) or {key}/... (actual workspace layout)
|
||||
@@ -183,10 +191,10 @@ func inferOwnerFromPath(relPath string, agentMap map[string]string, teamSet map[
|
||||
key, _, hasSlash := strings.Cut(rest, "/")
|
||||
if hasSlash && key != "" && !strings.Contains(relPath, "..") {
|
||||
if agentUUID, ok := agentMap[key]; ok {
|
||||
return &agentUUID, nil, "personal", relPath
|
||||
return &agentUUID, nil, nil, "personal", relPath
|
||||
}
|
||||
}
|
||||
return nil, nil, "", relPath
|
||||
return nil, nil, nil, "", relPath
|
||||
}
|
||||
|
||||
// Root-level agent_key match: {agent_key}/...
|
||||
@@ -194,12 +202,12 @@ func inferOwnerFromPath(relPath string, agentMap map[string]string, teamSet map[
|
||||
firstSeg, _, hasSlash := strings.Cut(relPath, "/")
|
||||
if hasSlash && firstSeg != "" {
|
||||
if agentUUID, ok := agentMap[firstSeg]; ok {
|
||||
return &agentUUID, nil, "personal", relPath
|
||||
return &agentUUID, nil, nil, "personal", relPath
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else is shared (root-level files, unknown folders)
|
||||
return nil, nil, "shared", relPath
|
||||
return nil, nil, nil, "shared", relPath
|
||||
}
|
||||
|
||||
// InferDocType guesses doc_type from path conventions.
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestInferOwnerFromPath(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
gotAgentID, gotTeamID, gotScope, gotPath := inferOwnerFromPath(tt.path, agentMap, teamSet)
|
||||
gotAgentID, gotTeamID, _, gotScope, gotPath := inferOwnerFromPath(tt.path, agentMap, teamSet)
|
||||
|
||||
if gotScope != tt.wantScope {
|
||||
t.Errorf("scope = %q, want %q", gotScope, tt.wantScope)
|
||||
|
||||
@@ -7,13 +7,60 @@ 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:
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Backfill 1: team-scoped docs (scope='team', team_id set).
|
||||
-- Two path layouts:
|
||||
-- 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/[^/]+/[^.][^/]*/';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Backfill 2: legacy docs from before team scope (team_id IS NULL) with chat
|
||||
-- identifiers embedded in their path. Without chat_id these leak across chats
|
||||
-- in isolated-team search because the `searchChatFilter` predicate cannot
|
||||
-- distinguish them.
|
||||
--
|
||||
-- Path layouts handled (ordered most-specific → most-general in COALESCE):
|
||||
-- telegram/group_telegram_<chat>/... (nested legacy)
|
||||
-- <agent_key>/telegram/group_telegram_<chat>/. (agent-owned nested)
|
||||
-- group_telegram_<chat>/... (bare legacy)
|
||||
-- /telegram/<chat>/... or leading telegram/<chat>/...
|
||||
-- tenants/<slug>/ws/<chat>/... (non-master tenant WS)
|
||||
-- ws/<chat>/... or <agent_key>/ws/<chat>/... (WS direct)
|
||||
-- <agent_key>/delegate/<chat>/... (delegated task)
|
||||
-- <agent_key>/<botname>/group_<botname>_<chat>/... (legacy bot channel)
|
||||
-- <agent_key>/<botname>/<chat>/... (bot + numeric/ws chat)
|
||||
--
|
||||
-- Chat IDs can be numeric (Telegram), `system`, user handles, etc.
|
||||
-- Only populate when chat_id IS NULL so interceptor-stamped values are preserved.
|
||||
-- -----------------------------------------------------------------------------
|
||||
UPDATE vault_documents
|
||||
SET chat_id = COALESCE(
|
||||
(regexp_match(path, '^telegram/group_telegram_(-?[0-9]+)/'))[1],
|
||||
(regexp_match(path, '/telegram/group_telegram_(-?[0-9]+)/'))[1],
|
||||
(regexp_match(path, '^group_telegram_(-?[0-9]+)/'))[1],
|
||||
(regexp_match(path, '/group_telegram_(-?[0-9]+)/'))[1],
|
||||
(regexp_match(path, '^telegram/(-?[0-9a-zA-Z_-]+)/'))[1],
|
||||
(regexp_match(path, '/telegram/([a-zA-Z0-9_-]+)/'))[1],
|
||||
(regexp_match(path, '^tenants/[^/]+/ws/([^/]+)/'))[1],
|
||||
(regexp_match(path, '^ws/([^/]+)/'))[1],
|
||||
(regexp_match(path, '^[^/]+/ws/([^/]+)/'))[1],
|
||||
(regexp_match(path, '^[^/]+/delegate/([^/]+)/'))[1],
|
||||
(regexp_match(path, '^[^/]+/[^/]+/group_[^/]+_(-?[0-9]+)/'))[1],
|
||||
(regexp_match(path, '^[^/]+/[^/]+/([a-zA-Z0-9_-]+)/'))[1]
|
||||
)
|
||||
WHERE chat_id IS NULL
|
||||
AND team_id IS NULL
|
||||
AND (
|
||||
path ~ '(^|/)(group_telegram_|telegram/)'
|
||||
OR path ~ '^(([^/]+/)?(tenants/[^/]+/)?)?ws/[^/]+/'
|
||||
OR path ~ '^[^/]+/delegate/[^/]+/'
|
||||
OR path ~ '^[^/]+/[^/]+/(group_[^/]+_-?[0-9]+|[0-9]+)/'
|
||||
);
|
||||
Reference in new issue
Block a user